Closures and decorators in depth
nonlocal and the UnboundLocalError it prevents, functools.wraps, decorators that take arguments, and the order stacked decorators run in.
After this lesson you can
- Keep state in a closure with nonlocal and explain the UnboundLocalError without it
- Write a decorator that preserves the wrapped function's name with functools.wraps
- Write a decorator factory that takes its own arguments
- Predict the order in which stacked decorators apply and run
Lesson 2 showed the core idea: @log_calls above a def means
add = log_calls(add), run once at definition time. Real decorators
need three more pieces — state, metadata, and arguments.
Closures that remember, and nonlocal
A nested function can read variables of the function around it, even after that function has returned. That captured environment is a closure, and it is how a decorator remembers which function it wraps.
Assigning is different. Python decides at compile time that any
name assigned inside a function is local to it, so count += 1 inside
the inner function refers to a local count that has no value yet, and
raises UnboundLocalError. nonlocal count says "the one in the
enclosing function"; global does the same for module level.
def make_ticketer(): count = 0 def next_ticket(): nonlocal count count += 1 return count return next_ticket def make_broken_ticketer(): count = 0 def next_ticket(): count += 1 # assignment makes count local here return count return next_ticket def run(): a, b = make_ticketer(), make_ticketer() calls = [a(), a(), a(), b()] try: make_broken_ticketer()() broken = "worked" except UnboundLocalError as e: broken = f"UnboundLocalError: {e}" return {"calls": calls, "broken": broken}functools.wraps
A decorator replaces your function with its wrapper, so without help
the decorated function reports the wrapper's __name__ and docstring.
Anything that reads those attributes — log lines built from
fn.__name__, help(), a web framework that names routes after their
functions — then sees wrapper everywhere. @functools.wraps(fn) on
the wrapper copies the name, qualified name, docstring, module and
annotations across, and stores the original in __wrapped__. (A
traceback still shows the frame as wrapper: it reads the name of the
code object, which wraps does not touch.)
Decorators with arguments
@retry(times=3) is a call first: retry(times=3) runs and must return
the actual decorator, which then receives the function. So a
configurable decorator is three nested levels — factory, decorator,
wrapper:
def tag(label): # the factory: takes the arguments
def decorate(fn): # the decorator: takes the function
@functools.wraps(fn)
def wrapper(*args, **kwargs): # replaces the function
return f"<{label}>{fn(*args, **kwargs)}</{label}>"
return wrapper
return decorate
import functools def tag(label): def decorate(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): return f"<{label}>{fn(*args, **kwargs)}</{label}>" return wrapper return decorate def tag_no_wraps(label): def decorate(fn): def wrapper(*args, **kwargs): return f"<{label}>{fn(*args, **kwargs)}</{label}>" return wrapper return decorate @tag("b")def greet(name): """Say hello.""" return f"hi {name}" @tag_no_wraps("b")def greet_plain(name): """Say hello.""" return f"hi {name}" def run(): return { "result": greet("Ana"), "name_with_wraps": greet.__name__, "doc_with_wraps": greet.__doc__, "original_kept": greet.__wrapped__("Ana"), "name_without": greet_plain.__name__, "doc_without": greet_plain.__doc__, "code_name_with_wraps": greet.__code__.co_name, }Stacking, and the decorators you already use
Stacked decorators apply bottom-up: the one nearest the def wraps the
function first, and the top one wraps the result. When the function is
called, the outermost wrapper runs first. functools.lru_cache (and
functools.cache) is a decorator that memoises results by argument —
arguments must be hashable — and property, staticmethod and
classmethod are decorators too.
import functools log = [] def mark(name): def decorate(fn): log.append(f"apply {name}") @functools.wraps(fn) def wrapper(*args): log.append(f"enter {name}") return fn(*args) return wrapper return decorate calls = 0 @functools.lru_cache(maxsize=None)def fib(n): global calls calls += 1 return n if n < 2 else fib(n - 1) + fib(n - 2) def run(): log.clear() @mark("top") @mark("bottom") def work(): log.append("work") log.append("--- call") work() return {"log": log, "fib_30": fib(30), "fib_calls": calls, "cache_hits": fib.cache_info().hits}Common mistakes
- Assigning to a captured variable without
nonlocal, and gettingUnboundLocalErrorthe first time the inner function runs. - Leaving out
functools.wraps, so every decorated function reports__name__wrapperand loses its docstring. - Writing
@retrywhere@retry(3)was meant, or the reverse. The function ends up passed as thetimesargument, or the decorator receives an integer instead of a function. - Caching with
lru_cacheon a function whose result depends on anything other than its arguments, or that takes a list.
What interviewers ask
- What is a closure? A function together with the variables it captured from an enclosing scope, which outlive that scope's call.
- How do you write a decorator that takes arguments? A factory that
returns the decorator; three levels, with
functools.wrapson the innermost. - In which order do stacked decorators apply? Nearest the
deffirst at definition time; at call time the outermost wrapper runs first.
Exercise · Pro
A call limit kept per function
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade