Python Atlas
Core language

Decorators and closures

Capture lexical state, build correct wrappers, preserve metadata, and choose explicit alternatives when wrapping hides too much.

Functions are objects: they can be stored, passed, returned, and defined inside other functions. A closure is a function that retains access to names from its enclosing lexical scope. A decorator receives an object and returns the object that should be bound to the decorated name.

Closure mechanics

from collections.abc import Callable


def make_counter(start: int = 0) -> Callable[[], int]:
    count = start

    def next_count() -> int:
        nonlocal count
        count += 1
        return count

    return next_count


counter = make_counter(10)
assert counter() == 11
assert counter() == 12

The returned function keeps the count cell alive after make_counter returns. nonlocal rebinds the nearest enclosing function-scope name. Without it, count += 1 would make count local to next_count and read it before local assignment.

Closures suit small, private state with one or a few operations. A class is often clearer when state has many fields, multiple public behaviors, introspection needs, or a meaningful domain identity.

Late binding

Closures capture variables, not snapshots of their values:

functions = [lambda: number for number in range(3)]
assert [function() for function in functions] == [2, 2, 2]

Each lambda reads the same number cell after the loop finishes. Capture the current value through a default argument or helper scope:

functions = [lambda number=number: number for number in range(3)]
assert [function() for function in functions] == [0, 1, 2]

Default expressions are evaluated when the function is defined. This technique is intentional capture, but a named helper can communicate it more clearly in complex code.

A correct function decorator

from collections.abc import Callable
from functools import wraps
from time import perf_counter
from typing import ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")


def timed(function: Callable[P, R]) -> Callable[P, R]:
    @wraps(function)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        started = perf_counter()
        try:
            return function(*args, **kwargs)
        finally:
            elapsed = perf_counter() - started
            print(f"{function.__qualname__}: {elapsed:.6f}s")

    return wrapper

ParamSpec preserves the wrapped callable's parameter types; TypeVar preserves its return type. functools.wraps copies metadata such as __name__ and __doc__, and sets __wrapped__ for inspection tools. The finally block records timing even when the function raises while allowing the original exception to propagate unchanged.

Decoration syntax is rebinding:

@timed
def calculate(value: int) -> int:
    return value * 2

is approximately:

def calculate(value: int) -> int:
    return value * 2


calculate = timed(calculate)

The decorator expression runs when the containing module or class body executes, usually at import time. The wrapper runs later on each call.

Decorator factories

A configurable decorator adds one more function layer:

from collections.abc import Callable
from functools import wraps
from typing import ParamSpec, TypeVar

P = ParamSpec("P")
R = TypeVar("R")


def repeat(times: int) -> Callable[[Callable[P, R]], Callable[P, R]]:
    if times < 1:
        raise ValueError("times must be positive")

    def decorate(function: Callable[P, R]) -> Callable[P, R]:
        @wraps(function)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            result = function(*args, **kwargs)
            for _ in range(times - 1):
                result = function(*args, **kwargs)
            return result

        return wrapper

    return decorate

@repeat(3) first calls repeat(3), then applies the returned decorator. Stacked decorators apply bottom-up:

@outer
@inner
def operation() -> None:
    pass

This binds operation = outer(inner(operation)). Call behavior then enters the outer wrapper before the inner wrapper.

Classes and descriptors

Decorators may return any compatible object. A class decorator receives a class. A callable instance can also implement a stateful decorator, but method decoration introduces descriptor details: a plain callable object does not automatically bind like a function unless it implements __get__.

Prefer function-based wrappers unless persistent decorator state or a richer object API justifies a class.

Keep important behavior visible

Decorators are excellent for orthogonal policies:

  • Metrics and tracing.
  • Caching pure or carefully keyed computations.
  • Registration.
  • Retry around a well-defined transient failure.
  • Authorization at an application boundary.

They become harmful when they obscure control flow, change return types surprisingly, swallow exceptions, depend on argument positions, or perform unrelated business logic. A named function call or context manager is clearer when callers should see when behavior starts and ends.

Tradeoffs and pitfalls

  • Mutable closure state is compact but can require synchronization across threads or asynchronous tasks.
  • A cache retains arguments and results; unbounded caches can retain memory.
  • A retry decorator must not blindly repeat non-idempotent side effects.
  • Wrappers must preserve async behavior; a synchronous wrapper around an async function only measures coroutine creation unless it awaits the call.
  • Decorators that replace signatures with *args, **kwargs weaken IDE and type checker support unless typed with ParamSpec.
  • Import-time registration relies on modules actually being imported.

Practice

  1. Write a closure that enforces a maximum number of successful calls.
  2. Fix the late-binding loop example using both a default argument and a helper function.
  3. Implement a typed logging decorator that preserves metadata and exceptions.
  4. Take one decorator that changes business behavior and rewrite it as an explicit collaborator or context manager.

LEARNING RECORD

Finish this lesson

Mark it complete when you can explain the main decision without looking.

KNOWLEDGE CHECK

Decorators and closures

1Why do lambdas created in a loop commonly all observe the loop’s final value?
2Which combination best preserves a wrapped function’s metadata and complete callable type?