Decorators, closures, and nonlocal

Build wrappers that keep metadata, state, and scope behavior correct

A decorator runs once at import time and replaces a function with another. When you write `@cache`, CPython executes the decorator call immediately during function definition. The closure is how the replacement remembers the original. CPython stores free variables in cell objects and reads them via `LOAD_DEREF` opcode, bridging scopes without copying values. The `nonlocal` keyword is required only when rebinding the outer name, not when mutating an object referenced by it. `functools.wraps` copies `__name__`, `__doc__`, and `__wrapped__` from the original to the wrapper. Decorators have existed since Python 2.4 (PEP 318). `nonlocal` was added in Python 3 (PEP 3104). <a href="/language-bytecode-dis">See how LOAD_DEREF and closure cells appear in bytecode</a>.

Understand.
Visualize.
Master.

Python in Depth

An interactive engineering reference for Python internals

Quick note

Always use `@wraps`.

:)
TABLE OF CONTENTS
2.4Decorators, closures, and nonlocal

Build wrappers that keep metadata, state, and scope behavior correct

Python lets you pass a function to another function, wrap it, and return a replacement. That is all a decorator is. And a function defined inside another function can keep using the outer function's variables after the outer function finishes — that is all a closure is. Neither is magic; both follow from functions being first-class objects with lexical scoping.

Think of a decorator like a mailing service. You hand over your envelope, the service adds a stamp and forwarding address, and hands back the same envelope with extra capability. The original letter is still inside. A closure is like a post office box: even after you walk away from the counter, the box still holds mail for you. The inner function retains access to variables from the enclosing scope, even after the outer function has returned.

Core answer

A decorator runs when the function definition executes, usually during module import, and rebinds the function name to whatever object the decorator returns.

# [CURRENT - 3.10-3.14] Works on Python 3.x
def trace(fn):
print("decorating", fn.__name__)
return fn
@trace
def run():
return "ok"

A closure is a function that retains access to free variables from an enclosing scope after that outer scope has returned.

# [CURRENT - 3.10-3.14] Works on Python 3.x
def make_averager():
values = []
def averager(value):
values.append(value)
return sum(values) / len(values)
return averager
avg = make_averager()
print(avg(10))
print(avg(20))
Mechanism and closure cells

Python uses lexical scoping: local, enclosing, global, then built-in name resolution. When an inner function references a variable from an enclosing function, CPython stores that binding in a closure cell.

# [CURRENT - 3.10-3.14] Works on Python 3.x
import dis
def outer():
x = 10
def inner(y):
return x + y
return inner
fn = outer()
print(fn.__code__.co_freevars)
print(fn.__closure__[0].cell_contents)
dis.dis(fn)

On CPython, the bytecode for the inner function uses LOAD_DEREF to read from the closure cell. That detail is implementation-specific, but it makes the mechanism concrete: the value stays in the closure cell and is read through LOAD_DEREF — never copied into the inner function body.

nonlocal is required only when rebinding the outer name, not when mutating an object referenced by that name.

# [CURRENT - 3.10-3.14] Works on Python 3.x
def make_counter():
count = 0
def inc():
nonlocal count
count += 1
return count
return inc
Version context

Decorators have been in Python since 2.4. nonlocal was added in Python 3 by PEP 3104. Current project guidance targets Python 3.10-3.14. Python 3.9 and below are End-of-Life.

Edge cases and gotchas

Decorators add another Python call boundary. In very hot paths, that extra frame and argument forwarding cost can matter.

Without functools.wraps, wrappers also lose metadata such as __name__, __doc__, and __wrapped__, which breaks introspection, debugging, and framework behavior.

# [CURRENT - 3.10-3.14] Works on Python 3.x
from functools import wraps
from time import perf_counter
def timed(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
start = perf_counter()
try:
return fn(*args, **kwargs)
finally:
elapsed = perf_counter() - start
print(f"{fn.__name__}: {elapsed:.6f}s")
return wrapper

Use closures for small amounts of hidden state. When the state has multiple operations, lifecycle concerns, or debugging importance, a class is often the clearer production abstraction.

Production usage

Use closures for lightweight factories, accumulators, and callbacks. Use decorators for orthogonal concerns such as timing, tracing, retries, and registration. Type decorators carefully when they are public API surface; see .

Further depth
  • Language reference: function definitions
  • Data model: user-defined functions
  • functools.wraps
  • PEP 3104: Access to Names in Outer Scopes
BOARD NOTESContext
WHY NO BENCHMARK?

This topic is better taught with structure, semantics, and cross-references than with a synthetic chart.

Always use `@wraps`.

RELATED GUIDES
NEXT CHECKS
Contribute