flowchart LR
subgraph Sugar["@decorate (sugar)"]
A1["def target()"] --> A2["@decorate"]
A2 --> A3["target = decorate(target)"]
end
subgraph Plain["plain assignment (desugared)"]
B1["def target()"] --> B2["target = decorate(target)"]
end
21 Decorators and Closures
Decorators are functions that wrap other functions. To truly understand them you must understand closures and how Python resolves variable names. Once you do, decorators become trivial — and powerful.
In this chapter you will learn to:
- Read
@decoratesyntax as the equivalenttarget = decorate(target). - Predict when decorator code runs (import time, not call time).
- State Python’s LEGB scope rule and avoid
UnboundLocalError. - Define a closure and use
nonlocalto rebind a free variable. - Write a decorator that preserves the wrapped function’s metadata.
- Use
functools.cache,lru_cache, andsingledispatch. - Build a decorator factory — a parameterized decorator.
21.1 Decorators 101
A decorator is syntactic sugar. Two forms are identical:
@decorate
def target(): ...
# is exactly equivalent to:
def target(): ...
target = decorate(target)The decorator is a function that takes a function and returns something (usually a function):
def deco(func):
def inner():
print("running inner()")
return inner
@deco
def target():
print("running target()")
target()running inner()
Notice the surprise: calling target runs inner, not the original body. deco returned inner, and the @deco line replaced target with that return value.
21.2 When Python executes decorators
Decorator code runs at import time — when the def is processed — not when the decorated function is called. This is the foundation of registry decorators:
registry = []
def register(func):
print(f"running register({func.__name__})")
registry.append(func)
return func
@register
def f1(): pass
@register
def f2(): pass
[f.__name__ for f in registry]running register(f1)
running register(f2)
['f1', 'f2']
The two running register(...) lines printed when the cell defined the functions, not when something called them. This timing is what lets decorators register, count, or transform on definition.
21.3 Variable scope rules
To understand decorators you must understand Python’s LEGB rule: a name is resolved in Local, Enclosing, Global, then Built-in scope.
b = 6
def f1(a):
print(a, b)
f1(3)3 6
b is read from the global scope. But this fails:
b = 6
def f2(a):
print(a, b)
b = 9
f2(3)--------------------------------------------------------------------------- UnboundLocalError Traceback (most recent call last) Cell In[4], line 5 1 b = 6 2 def f2(a): 3 print(a, b) 4 b = 9 ----> 5 f2(3) Cell In[4], line 3, in f2(a) 2 def f2(a): ----> 3 print(a, b) 4 b = 9 UnboundLocalError: cannot access local variable 'b' where it is not associated with a value
UnboundLocalError. Python sees the assignment b = 9 and decides b is local throughout f2 — even before the assignment line. The earlier print(b) then fails because the local b hasn’t been bound yet.
The fix is to declare your intent:
b = 6
def f3(a):
global b
print(a, b)
b = 9
f3(3)
b3 6
9
global says “the b I’m writing to is the module-level one.” We’ll meet nonlocal next, which plays the same role for enclosing scopes.
21.4 Closures
A function that needs to remember things across calls usually holds them as instance state on a class. But classes are heavy when the state is just one or two variables. Python’s lighter alternative is a closure: a function that retains the bindings of free variables from its enclosing scope, even after the enclosing function has returned.
def make_averager():
series = []
def averager(new_value):
series.append(new_value)
return sum(series) / len(series)
return averager
avg = make_averager()
avg(10), avg(11), avg(12)(10.0, 10.5, 11.0)
Walking through what just happened:
make_averagerdefines a local variableseries = []and a nested functionaverager.averagerreads and mutatesserieseven thoughseriesisn’t in its own local scope —seriesis a free variable (free inaverager, bound in the enclosingmake_averager).make_averager()returns the inner function. Normally a function’s locals (hereseries) are discarded when it returns; but becauseaveragerreferencesseries, Python keeps that binding alive in a hidden cell attached toaverager.avg(10),avg(11),avg(12)all share the sameserieslist — calls accumulate. That’s the closure:make_averager’s frame is gone, but theseriesit created lives on insideavg.
You can look at the capture directly:
avg.__code__.co_freevars('series',)
avg.__closure__[0].cell_contents[10, 11, 12]
21.5 nonlocal
The closure trick works because series.append mutates the list — it doesn’t rebind the name. Try the same idea with an int:
def make_averager_v2():
count = 0
total = 0
def averager(new_value):
count += 1 # tries to rebind 'count'
total += new_value
return total / count
return averager
avg = make_averager_v2()
avg(10)--------------------------------------------------------------------------- UnboundLocalError Traceback (most recent call last) Cell In[9], line 11 7 return total / count 8 return averager 9 10 avg = make_averager_v2() ---> 11 avg(10) Cell In[9], line 5, in make_averager_v2.<locals>.averager(new_value) 4 def averager(new_value): ----> 5 count += 1 # tries to rebind 'count' 6 total += new_value 7 return total / count UnboundLocalError: cannot access local variable 'count' where it is not associated with a value
Same UnboundLocalError. count += 1 is a rebinding, so Python decides count is local to averager. The fix:
def make_averager_v3():
count = 0
total = 0
def averager(new_value):
nonlocal count, total
count += 1
total += new_value
return total / count
return averager
avg = make_averager_v3()
avg(10), avg(11), avg(12)(10.0, 10.5, 11.0)
nonlocal count, total says “these names refer to the enclosing scope; I’m rebinding them there.”
21.6 A simple decorator
Putting it all together, a clock decorator that times a call. The problem: you want to add timing to several functions without editing each body. The solution: a wrapper function that records the time around the original, and a decorator to install it.
import time, functools
def clock(func):
@functools.wraps(func)
def clocked(*args, **kwargs):
t0 = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - t0
arg_lst = [repr(a) for a in args]
arg_lst.extend(f"{k}={v!r}" for k, v in kwargs.items())
print(f"[{elapsed:0.6f}s] {func.__name__}({', '.join(arg_lst)}) -> {result!r}")
return result
return clocked
@clock
def factorial(n):
return 1 if n < 2 else n * factorial(n - 1)
factorial(6)[0.000000s] factorial(1) -> 1
[0.000069s] factorial(2) -> 2
[0.000096s] factorial(3) -> 6
[0.000149s] factorial(4) -> 24
[0.000163s] factorial(5) -> 120
[0.000177s] factorial(6) -> 720
720
Walking through the layers:
clock(func)is the decorator. It receives the function being decorated (factorial) and returns a replacement function (clocked).clocked(*args, **kwargs)is the wrapper. The*args, **kwargssignature accepts whatever shapefunctakes — that’s what makesclockreusable across functions.- Inside
clocked,funcis a free variable — it was captured from the enclosingclockframe. This is the closure pattern from the previous section, doing real work. time.perf_counter()is the highest-resolution wall-clock timer Python exposes; calling it before and afterfunc(*args, **kwargs)measures elapsed seconds.@functools.wraps(func)is the small detail that matters. Without it,clockedwould replacefactorial— its__name__,__doc__, and__wrapped__would all point to the wrapper, not the original. With it, the wrapper looks like the wrapped function to the outside world.@clockabovedef factorialis sugar forfactorial = clock(factorial). After the line runs, the global namefactorialrefers toclocked, with the original function captured inside.
The general pattern: wrap, time (or log, or cache, or…), forward, return. Every “add behavior around a function” decorator follows this shape.
21.7 Decorators in the standard library
functools ships three decorators you’ll reach for routinely.
@cache (Python 3.9+) — memoize without a size limit, no eviction. Equivalent to @lru_cache(maxsize=None):
@functools.cache
def fibonacci(n):
return n if n < 2 else fibonacci(n-1) + fibonacci(n-2)
fibonacci(30)832040
The recursion that’s O(2^n) without caching becomes O(n) with it.
@lru_cache(maxsize=N) — bounded memoization. When the cache fills, the least recently used entry is evicted. Use @cache when keys are bounded (small integers, an enum); use @lru_cache(maxsize=N) when the input space is open-ended and you want a memory ceiling.
@functools.cached_property — a related tool: compute an expensive instance attribute once on first access, store it on the instance, return the stored value thereafter. Like @cache but per-instance and tied to attribute access.
@singledispatch — write a “generic function” that dispatches on the first argument’s type. The naive way to render a value as HTML is a long if isinstance(x, str): ... elif isinstance(x, list): ... chain — which couples every type to one big function and forces you to edit it whenever you add a new case. singledispatch flips this: each type gets its own implementation, registered separately, looked up by type at call time:
import html
from functools import singledispatch
@singledispatch
def htmlize(obj) -> str:
content = html.escape(repr(obj))
return f"<pre>{content}</pre>"
@htmlize.register
def _(text: str) -> str:
content = html.escape(text).replace("\n", "<br/>\n")
return f"<p>{content}</p>"
@htmlize.register
def _(seq: list) -> str:
inner = "\n".join(f"<li>{htmlize(x)}</li>" for x in seq)
return f"<ul>\n{inner}\n</ul>"
@htmlize.register
def _(n: int) -> str:
return f"<pre>{n} (0x{n:02X})</pre>"
print(htmlize("hello"))
print(htmlize(42))
print(htmlize([1, "two", 3]))<p>hello</p>
<pre>42 (0x2A)</pre>
<ul>
<li><pre>1 (0x01)</pre></li>
<li><p>two</p></li>
<li><pre>3 (0x03)</pre></li>
</ul>
Walking through the dispatch mechanism:
@singledispatchdecorates the base functionhtmlize(obj). This becomes the fallback — used when no registered handler matches.@htmlize.register(note: it’s the decorated function’s own attribute, set bysingledispatch) adds a specialized implementation. The type to dispatch on comes from the parameter annotation —text: strregisters this body forstrarguments.- The body name
_is the convention for “I don’t need to refer to this by name” — the dispatch table is what matters; the binding_is just the by-product ofdef. - At call time,
htmlize(42)looks up42’s type (int) in the dispatch table, finds theinthandler, and calls it.htmlize(some_unregistered_thing)falls through to the originalhtmlize(obj).
The general pattern: singledispatch is the Pythonic alternative to a chain of isinstance branches, and crucially, new types can be added in different files — each just needs @htmlize.register on its handler.
21.8 Parameterized decorators
A decorator that takes arguments is really a factory that returns a decorator. The phrase to remember: @deco(arg) means “call deco(arg) first, then apply the result as a decorator.” Build it up in three steps.
Step 1: a factory that returns a no-op decorator. When the decorator only inspects or registers the function (no behavior change at call time), the nesting is two levels deep — factory plus decorator:
def register_if(active=True):
def decorate(func):
if active:
registry.append(func)
return func
return decorate
registry = []
@register_if(active=False)
def g1(): pass
@register_if(active=True)
def g2(): pass
[f.__name__ for f in registry]['g2']
register_if(active=True)evaluates first, returning the actual decoratordecorate.decoratethen receivesg2and registers it before returning it unchanged.register_if(active=False)returns adecoratewhoseif active:branch is skipped — sog1never enters the registry.
Step 2: add a wrapper — three levels deep. When the decorator changes the call’s behavior, the wrapper is the third level. Here’s a minimal repeat(n) that runs the function n times:
def repeat(n):
def decorate(func):
def wrapper(*args, **kwargs):
for _ in range(n - 1):
func(*args, **kwargs)
return func(*args, **kwargs)
return wrapper
return decorate
@repeat(3)
def shout(word):
print(word.upper())
shout("hi")HI
HI
HI
repeat(n)is the factory — takes the configuration argument, returns the decorator.decorate(func)is the decorator — takes the function, returns the wrapper.wrapper(*args, **kwargs)is the wrapper — replaces the original at every call site. It readsnfrom the enclosing scope through closure.- Reading order is outside-in:
@repeat(3)runsrepeat(3)→ returnsdecorate→ applied toshout→ returnswrapper, bound to the nameshout.
Step 3: the production version. A real parameterized decorator also forwards arguments, returns the result, and uses @functools.wraps so help() and tracebacks see the original. Here’s a parameterized clock where the caller picks the output format:
DEFAULT_FMT = "[{elapsed:0.8f}s] {name}({args}) -> {result}"
def clock(fmt=DEFAULT_FMT):
def decorate(func):
@functools.wraps(func)
def clocked(*args, **kwargs):
t0 = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - t0
print(fmt.format(
elapsed=elapsed,
name=func.__name__,
args=", ".join(repr(a) for a in args),
result=repr(result),
))
return result
return clocked
return decorate
@clock("{name}: {elapsed:.4f}s")
def snooze(seconds):
time.sleep(seconds)
snooze(0.01)snooze: 0.0101s
- The three levels are the same as
repeat: factoryclock, decoratordecorate, wrapperclocked. fmtis captured by closure —clockedreads it through the enclosingdecoratescope, which read it from the outermostclockcall.@functools.wraps(func)keepssnooze.__name__ == "snooze"instead of"clocked"— essential for tracebacks andhelp().
So @clock("{name}: {elapsed:.4f}s") desugars to snooze = clock("{name}: {elapsed:.4f}s")(snooze) — the factory call evaluates first, then its result decorates. The general rule: a decorator with arguments is one extra level of nesting — the outermost takes the arguments, returns a decorator that takes the function, that returns the wrapper.
A closure is a function that remembers the enclosing scope’s variables even after the enclosing function has returned. A decorator is a function that takes a function and returns a (modified) function. These are not tricks — they are direct consequences of functions being objects and Python having lexical scoping.
21.9 Build: a stackable @validated(predicate) decorator
A production function often guards its first argument against several invariants — “must be positive”, “must be a string”, “must be in the allowed set”. One common pattern is a chain of if not …: raise ValueError(...) lines at the top of every function. A parameterised decorator turns those into named, reusable, stackable checks.
Step 1: a parameterised factory that takes a predicate. Three levels of nesting — factory, decorator, wrapper — exactly the shape from the chapter:
import functools
def validated(predicate, message="invalid"):
def decorate(func):
@functools.wraps(func)
def wrapper(value, *args, **kwargs):
if not predicate(value):
raise ValueError(f"{message}: {value!r}")
return func(value, *args, **kwargs)
return wrapper
return decorate
@validated(lambda x: x > 0, message="must be positive")
def square(n):
return n * n
square(5)25
validated(predicate, message) evaluates first and returns the actual decorator decorate. decorate wraps square in wrapper, which checks predicate(value) before delegating. predicate and message are captured by closure — wrapper reads them from the enclosing scopes.
square(-3)--------------------------------------------------------------------------- ValueError Traceback (most recent call last) Cell In[18], line 1 ----> 1 square(-3) Cell In[17], line 8, in validated.<locals>.decorate.<locals>.wrapper(value, *args, **kwargs) 5 @functools.wraps(func) 6 def wrapper(value, *args, **kwargs): 7 if not predicate(value): ----> 8 raise ValueError(f"{message}: {value!r}") 9 return func(value, *args, **kwargs) ValueError: must be positive: -3
The wrapper raises ValueError before calling square, so the function body never sees a negative input. @functools.wraps(func) keeps square.__name__ == "square" and the docstring intact for help() and tracebacks.
Step 2: stack multiple validators. Decorators compose — each @validated adds another wrapper layer. Stacking order matters: the bottom decorator wraps first, so its check runs last:
@validated(lambda x: isinstance(x, int), message="must be int")
@validated(lambda x: x > 0, message="must be positive")
def square(n):
return n * n
square(5)25
square(-3)--------------------------------------------------------------------------- ValueError Traceback (most recent call last) Cell In[20], line 1 ----> 1 square(-3) Cell In[17], line 9, in validated.<locals>.decorate.<locals>.wrapper(value, *args, **kwargs) 5 @functools.wraps(func) 6 def wrapper(value, *args, **kwargs): 7 if not predicate(value): 8 raise ValueError(f"{message}: {value!r}") ----> 9 return func(value, *args, **kwargs) Cell In[17], line 8, in validated.<locals>.decorate.<locals>.wrapper(value, *args, **kwargs) 5 @functools.wraps(func) 6 def wrapper(value, *args, **kwargs): 7 if not predicate(value): ----> 8 raise ValueError(f"{message}: {value!r}") 9 return func(value, *args, **kwargs) ValueError: must be positive: -3
square(3.14)--------------------------------------------------------------------------- ValueError Traceback (most recent call last) Cell In[21], line 1 ----> 1 square(3.14) Cell In[17], line 8, in validated.<locals>.decorate.<locals>.wrapper(value, *args, **kwargs) 5 @functools.wraps(func) 6 def wrapper(value, *args, **kwargs): 7 if not predicate(value): ----> 8 raise ValueError(f"{message}: {value!r}") 9 return func(value, *args, **kwargs) ValueError: must be int: 3.14
Reading bottom-to-top: @validated(positive) wraps square first, producing wrapper_positive. Then @validated(int) wraps that, producing wrapper_int. At call time the layers run top-to-bottom: square(3.14) enters wrapper_int, fails the int check, raises before the positive wrapper or the original ever runs. square(-3) passes the int check, then fails the positive check.
Step 3: a small library of named validators. The whole point of factoring this out is reuse. Build the predicates as named values, not anonymous lambdas:
is_int = validated(lambda x: isinstance(x, int), message="must be int")
is_positive = validated(lambda x: x > 0, message="must be positive")
in_range = lambda lo, hi: validated(
lambda x: lo <= x <= hi,
message=f"must be in [{lo}, {hi}]",
)
@is_int
@is_positive
@in_range(1, 100)
def percentile(n):
return f"the {n}th percentile"
[percentile(50), percentile(1)]['the 50th percentile', 'the 1th percentile']
percentile(150)--------------------------------------------------------------------------- ValueError Traceback (most recent call last) Cell In[23], line 1 ----> 1 percentile(150) Cell In[17], line 9, in validated.<locals>.decorate.<locals>.wrapper(value, *args, **kwargs) 5 @functools.wraps(func) 6 def wrapper(value, *args, **kwargs): 7 if not predicate(value): 8 raise ValueError(f"{message}: {value!r}") ----> 9 return func(value, *args, **kwargs) Cell In[17], line 9, in validated.<locals>.decorate.<locals>.wrapper(value, *args, **kwargs) 5 @functools.wraps(func) 6 def wrapper(value, *args, **kwargs): 7 if not predicate(value): 8 raise ValueError(f"{message}: {value!r}") ----> 9 return func(value, *args, **kwargs) Cell In[17], line 8, in validated.<locals>.decorate.<locals>.wrapper(value, *args, **kwargs) 5 @functools.wraps(func) 6 def wrapper(value, *args, **kwargs): 7 if not predicate(value): ----> 8 raise ValueError(f"{message}: {value!r}") 9 return func(value, *args, **kwargs) ValueError: must be in [1, 100]: 150
is_int and is_positive are concrete decorators — validated(...) already evaluated, ready to apply. in_range(lo, hi) is a fourth level of nesting: a function that returns a decorator factory call. So @in_range(1, 100) calls in_range(1, 100) (returns the decorator), which wraps the function. Each layer reads its captured config from its own closure — no shared state, no global registry, no class needed.
The build threads everything from the chapter through one running pattern: closures (predicates and messages captured by the wrapper), parameterised decorators (factory → decorator → wrapper), @functools.wraps for metadata preservation, and the inside-out reading order of stacked decorators.
21.10 Exercises
Order of decorators. What’s the difference between
@a @b def fand@b @a def f? Try aclockand acachedecorator, both ways. Which call shows the timing?Closures over a loop variable. Write
make_funcs(n)that returns a list ofnzero-argument functions, where each one returns its index. Uselambda i: iin a list comprehension and watch what happens when you call them. Now fix it.functools.wraps. Remove@functools.wrapsfrom theclockdecorator. Printclocked.__name__andclocked.__doc__. What changed?@cacheon a method. Define a class with a method decorated with@cache. Call it on two instances. Predict whether they share the cache. (This is a real-world pitfall.)Custom
singledispatch. Add a fourthhtmlizeregistration fordictthat renders a definition list (<dl>/<dt>/<dd>). Test it.
Beazley, Python Distilled §5.18–5.22 covers function introspection, dynamic execution, and the runtime-introspection patterns decorators rely on. Read it for a low-level view of why functools.wraps matters and what Python’s call machinery looks like underneath.
21.11 Summary
Decorators are sugar for func = decorator(func). They run at import time. Their power comes from closures, which let an inner function carry state from the outer one even after the outer returns. With these two ideas plus functools.wraps, you can write any decorator the standard library ships and most of those you’ll encounter in real projects.
Next, Chapter 22 shows what happens when you take this view to its conclusion: classic OOP design patterns mostly disappear when functions are first-class.