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 dataclassfrom functools import wrapsfrom time import perf_counterfrom typing import Callable, TypeVarT = TypeVar("T")@dataclass(frozen=True, slots=True)class Metric: name: str elapsed_ms: floatdef 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@timeddef 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 dataclassfrom dis import disfrom typing import Callable@dataclass(frozen=True, slots=True)class CounterSnapshot: name: str calls: intdef make_counter(name: str) -> Callable[[], CounterSnapshot]: calls = 0 def count() -> CounterSnapshot: nonlocal calls calls += 1 return CounterSnapshot(name, calls) return countorders = 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 dataclassfrom functools import wrapsfrom typing import Callable, ParamSpec, TypeVarP = ParamSpec("P")R = TypeVar("R")@dataclass(frozen=True, slots=True)class AuditLine: action: str args_seen: intdef 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@auditeddef 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.