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`.

:)
Python version

Targets Python 3.10–3.14. Python 3.9 and below are End-of-Life.

TABLE OF CONTENTS
2.4Decorators, closures, and nonlocal

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

Decorators are import-time rebinding. Closures are function objects that retain bindings from an enclosing lexical scope. They are often used together, but the failure modes are different.

Core answer

Use decorators for cross-cutting behavior that keeps the wrapped function's contract legible. Use closures for small factories that capture stable configuration. Preserve metadata with functools.wraps, and bind loop variables deliberately when building many closures.

# [CURRENT - 3.10-3.14] Works on Python 3.10+
from dataclasses import dataclass
from functools import wraps
from time import perf_counter
from typing import Callable, TypeVar
T = TypeVar("T")
@dataclass(frozen=True, slots=True)
class Metric:
name: str
elapsed_ms: float
def timed(func: Callable[[], T]) -> Callable[[], T]:
@wraps(func)
def wrapper() -> T:
started = perf_counter()
result = func()
print(Metric(func.__name__, (perf_counter() - started) * 1000))
return result
return wrapper
@timed
def refresh_cache() -> int:
return sum(range(10_000))
print(refresh_cache())

Why this design exists

PEP 318 made decoration explicit syntax for a rebinding pattern that already existed. PEP 3104 added nonlocal so inner functions can rebind enclosing-scope names without collapsing everything into globals or mutable containers.

The design keeps functions first-class. A wrapper can preserve, replace, register, cache, or measure another callable because a function definition produces an object and @decorator rewrites the binding immediately.

Mechanics and CPython internals

CPython stores captured variables in closure cells. Inner code loads those cells with closure-aware bytecode such as LOAD_DEREF; the exact disassembly is version-sensitive, but the lexical scoping rule is language behavior. Mutating a captured list does not require nonlocal; rebinding an integer counter does.

# [CURRENT - 3.10-3.14] Works on Python 3.10+
from dataclasses import dataclass
from dis import dis
from typing import Callable
@dataclass(frozen=True, slots=True)
class CounterSnapshot:
name: str
calls: int
def make_counter(name: str) -> Callable[[], CounterSnapshot]:
calls = 0
def count() -> CounterSnapshot:
nonlocal calls
calls += 1
return CounterSnapshot(name, calls)
return count
orders = make_counter("orders")
print(orders())
print(orders.__closure__)
dis(orders)

Complexity and tradeoffs

A wrapper adds at least one extra Python call layer and any work the decorator performs. A closure cell lookup is usually small compared with I/O or domain work, but a wrapper in a tight hot loop can become measurable. Decorators improve consistency; they can also hide control flow, retry behavior, or synchronization where callers least expect it.

Idiomatic patterns and refactoring

Refactor wrappers that lose metadata and keyword arguments before framework introspection starts relying on them.

# [CURRENT - 3.10-3.14] Works on Python 3.10+
from dataclasses import dataclass
from functools import wraps
from typing import Callable, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
@dataclass(frozen=True, slots=True)
class AuditLine:
action: str
args_seen: int
def audited(func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
def wrapped(*args: P.args, **kwargs: P.kwargs) -> R:
print(AuditLine(func.__name__, len(args) + len(kwargs)))
return func(*args, **kwargs)
return wrapped
@audited
def publish(order_id: str, *, topic: str) -> str:
return f"{topic}:{order_id}"
print(publish("ORD-7", topic="audit"))

Common mistakes and edge cases

The classic late-binding closure bug appears when lambdas in a loop all read the same final loop variable. Bind the current value through an enclosing factory or a default value whose early binding is intentional. Do not decorate a function with side effects at import time unless import side effects are part of the module contract.

When to use / When NOT to use

Use closures for narrow configured callables and decorators for behavior that should be declared next to the function definition.

Do not use decorators to hide business branching, retries, transactions, or security policy from readers who need those operations visible at the call boundary.

Further reading

  • Official docs: function definitions
  • Official docs: functools.wraps
  • PEP 318: decorators
  • PEP 3104: nonlocal
  • CPython source: function objects
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