A strong answer
When an inner function references a variable i from an enclosing scope, CPython compiles i as a free variable stored in a heap-allocated PyCellObject (__closure__). Crucially, the closure captures the cell pointer to the variable i, NOT the value i had when the lambda was created (late binding)! By the time the loop for i in range(4) finishes, the shared cell i holds 3, so all 4 lambdas read 3 from that same cell when called later. Binding the current value as a default argument lambda i=i: run_step(i) evaluates i eagerly at function creation time (__defaults__). Furthermore, a decorator @dec replaces fn = dec(fn); without @functools.wraps(fn), the wrapper hides fn.__name__, fn.__doc__, fn.__annotations__, and fn.__wrapped__, breaking frameworks like FastAPI, pytest, and inspect.signature that introspect type annotations.
A closure variable resolved via LOAD_DEREF reads the current ob_ref inside the shared PyCellObject at call time (late binding), whereas a default parameter i=i in fn.__defaults__ captures the PyObject* reference at function definition time (early binding). Because all iterations of a loop in the same scope share a single PyCellObject for the loop variable i, mutating i on each iteration updates cell->ob_ref in place; only creating a new binding per iteration (via default argument i=i or a factory function make_handler(i)) allocates a distinct reference per callback.
LOAD_DEREF (closure cell lookup) adds only 1 pointer indirection over LOAD_FAST, and a @functools.wraps decorator adds ~50–100 ns per call while preserving full runtime type introspection. In production systems (FastAPI route/dependency introspection (inspect.signature following __wrapped__), functools.lru_cache, pytest.fixture, and React/Python UI event callbacks all depend on closure cell binding and @functools.wraps.), engineers monitor these exact trade-offs to keep p99 latency predictable and prevent resource exhaustion under peak load.
What the interviewer is listening for
- Traces how lexical closures (
PyCellObject), late binding, and@functools.wrapsdecorators operates in memory step by step rather than giving a vague textbook definition. - States the core correctness invariant and explains why edge cases do not violate it.
- Quantifies time, space, and hardware costs (Local Variable (
LOAD_FAST), Closure Cell (LOAD_DEREF)) with concrete numbers. - Identifies real-world failure modes (Defining callbacks
[lambda: send(u) for u in users]inside a loop) and explains how production systems mitigate them.
Common mistakes
- Treating lexical closures (
PyCellObject), late binding, and@functools.wrapsdecorators as a black box without explaining the underlying memory or state transitions. - Ignoring worst-case tail latency or memory overhead (Omitting
@functools.wraps(fn)in a custom decorator). - Forgetting boundary conditions and error recovery (Decorating an
async defcoroutine with a synchronouswrapperthat returnsfn(*args)withoutawait).