21  Decorators and Closures

NoteCore idea

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:

  1. Read @decorate syntax as the equivalent target = decorate(target).
  2. Predict when decorator code runs (import time, not call time).
  3. State Python’s LEGB scope rule and avoid UnboundLocalError.
  4. Define a closure and use nonlocal to rebind a free variable.
  5. Write a decorator that preserves the wrapped function’s metadata.
  6. Use functools.cache, lru_cache, and singledispatch.
  7. 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)

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

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)
b
3 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_averager defines a local variable series = [] and a nested function averager. averager reads and mutates series even though series isn’t in its own local scope — series is a free variable (free in averager, bound in the enclosing make_averager).
  • make_averager() returns the inner function. Normally a function’s locals (here series) are discarded when it returns; but because averager references series, Python keeps that binding alive in a hidden cell attached to averager.
  • avg(10), avg(11), avg(12) all share the same series list — calls accumulate. That’s the closure: make_averager’s frame is gone, but the series it created lives on inside avg.

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, **kwargs signature accepts whatever shape func takes — that’s what makes clock reusable across functions.
  • Inside clocked, func is a free variable — it was captured from the enclosing clock frame. 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 after func(*args, **kwargs) measures elapsed seconds.
  • @functools.wraps(func) is the small detail that matters. Without it, clocked would replace factorial — 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.
  • @clock above def factorial is sugar for factorial = clock(factorial). After the line runs, the global name factorial refers to clocked, 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:

  • @singledispatch decorates the base function htmlize(obj). This becomes the fallback — used when no registered handler matches.
  • @htmlize.register (note: it’s the decorated function’s own attribute, set by singledispatch) adds a specialized implementation. The type to dispatch on comes from the parameter annotation — text: str registers this body for str arguments.
  • 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 of def.
  • At call time, htmlize(42) looks up 42’s type (int) in the dispatch table, finds the int handler, and calls it. htmlize(some_unregistered_thing) falls through to the original htmlize(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 decorator decorate. decorate then receives g2 and registers it before returning it unchanged.
  • register_if(active=False) returns a decorate whose if active: branch is skipped — so g1 never 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 reads n from the enclosing scope through closure.
  • Reading order is outside-in: @repeat(3) runs repeat(3) → returns decorate → applied to shout → returns wrapper, bound to the name shout.

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: factory clock, decorator decorate, wrapper clocked.
  • fmt is captured by closure — clocked reads it through the enclosing decorate scope, which read it from the outermost clock call.
  • @functools.wraps(func) keeps snooze.__name__ == "snooze" instead of "clocked" — essential for tracebacks and help().

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.

TipWhy this matters

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

  1. Order of decorators. What’s the difference between @a @b def f and @b @a def f? Try a clock and a cache decorator, both ways. Which call shows the timing?

  2. Closures over a loop variable. Write make_funcs(n) that returns a list of n zero-argument functions, where each one returns its index. Use lambda i: i in a list comprehension and watch what happens when you call them. Now fix it.

  3. functools.wraps. Remove @functools.wraps from the clock decorator. Print clocked.__name__ and clocked.__doc__. What changed?

  4. @cache on 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.)

  5. Custom singledispatch. Add a fourth htmlize registration for dict that renders a definition list (<dl> / <dt> / <dd>). Test it.

NoteFurther reading

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.