Late Binding Closures

The late-binding-closure trap is the closure counterpart of the mutable-default trap: a loop that builds a list of functions, each of which references the loop variable, produces functions that all return the loop variable’s final value, not the value it had when each function was created. The canonical demonstration is [lambda: i for i in range(3)], whose three lambdas all return 2 (not 0, 1, 2) when called. The reason is that a closure captures a binding, not a value: all three lambdas close over the same loop-variable cell, the loop mutates that one cell on each iteration, and by the time any lambda is called, the loop has finished and the cell holds its last value. The cell machinery — MAKE_CELL, the shared PyCellObject, LOAD_DEREF reading through it — is the subject of Cell Variables and Closures; this note owns the trap: how it shows up in real code (event handlers, deferred callbacks, partials), how to recognize it, and the three idiomatic fixes (default-argument capture, functools.partial, a factory function), each with its trade-offs. Verified against CPython 3.14.5.

The Trap, Verified

On CPython 3.14.5, both the comprehension form and the explicit-loop form produce identical, surprising output:

# Comprehension form
fns = [lambda: i for i in range(3)]
[f() for f in fns]            # [2, 2, 2]   -- not [0, 1, 2]
 
# Explicit-loop form
fns = []
for i in range(3):
    fns.append(lambda: i)
[f() for f in fns]            # [2, 2, 2]   -- identical surprise

Every lambda returns 2. The number 2 is the last value the loop variable i took. The lambdas did not snapshot i at the moment each was created; they all read the one live i when called, long after the loop ended.

flowchart TD
    L0["loop: i = 0  -> create lambda A"] --> L1["loop: i = 1  -> create lambda B"]
    L1 --> L2["loop: i = 2  -> create lambda C"]
    L2 --> DONE["loop ends; the ONE cell for i holds 2"]
    DONE --> CALL["LATER: call A(), B(), C()"]
    CALL --> READ["each lambda runs LOAD_DEREF on the SAME cell<br/>-> reads 2 -> returns 2"]
    READ --> OUT["[2, 2, 2]"]

    A["lambda A: return i"] -.shares.-> CELL[("one shared cell for i")]
    B["lambda B: return i"] -.shares.-> CELL
    C["lambda C: return i"] -.shares.-> CELL

    style CELL fill:#fdf6e3,stroke:#b58900
    style OUT fill:#ffe8e8

Diagram: three lambdas, one cell. The lambdas are created across three iterations, but all three close over the single PyCellObject that holds the loop variable i. The loop mutates that one cell each iteration, so after the loop it holds 2. The lambdas are not called until later (red node), and each reads the live cell via LOAD_DEREF — getting 2. The insight: closure capture binds the variable, not its value-at-creation-time, so deferring the call until after the loop is what exposes the trap.

Why It Happens (one paragraph — mechanism lives elsewhere)

A closure in CPython captures a cell: a one-slot heap box shared between the scope that owns a variable and every inner function that references it. When an inner function reads a free variable, the compiler emits LOAD_DEREF, which fetches the shared cell and dereferences it to get whatever the cell holds at that instant. In the loop above, the loop variable i is referenced by the lambdas, so the symbol table classifies it as a cell variable; the loop’s assignment on each iteration is a STORE_DEREF into that one cell; and each lambda’s body is a LOAD_DEREF from that same cell. There is exactly one cell, so there is no per-iteration snapshot to capture — the lambdas all see whatever the cell holds when they run, which (because they run after the loop) is the final value. This is late binding working exactly as the execution model specifies: a free name is resolved when the code that uses it executes, not when the function is defined (execution model). The disassembly proof and the MAKE_CELL/STORE_DEREF/COPY_FREE_VARS/LOAD_DEREF walk-through are in Cell Variables and Closures; this note builds on that result rather than re-deriving it.

The disassembly confirms the shared cell directly. For [lambda: i for i in range(3)] on 3.14.5, the comprehension’s body contains MAKE_CELL 0 (i), the loop step does STORE_DEREF 0 (i), and each lambda is built with SET_FUNCTION_ATTRIBUTE 8 (closure) over a code object whose body is COPY_FREE_VARS 1 then LOAD_DEREF 0 (i). That is the unmistakable signature of a shared cell: one box, written by the loop, read by every lambda.

Comprehension inlining (PEP 709) does not change this result

Since Python 3.12, list/set/dict comprehensions are inlined into the enclosing frame rather than getting their own function frame (PEP 709). It is tempting to think this changed the closure trap — it did not. The inlining changed only the isolation of the comprehension’s loop variable (i does not leak into the surrounding scope, implemented via a LOAD_FAST_AND_CLEAR save/restore you can see in the disassembly). The closure capture is still a shared cell, and [lambda: i for i in range(3)] still yields [2, 2, 2] — the result is version-stable across the comprehension-scoping change. The interaction between this cell machinery and comprehension isolation is detailed in Comprehension Scoping; the takeaway here is only that you should not attribute the [2,2,2] result to inlining.

Real-World Incarnations

The toy lambda: i rarely appears verbatim; the trap reaches production disguised as event handlers, deferred callbacks, and confused partial usage. The common thread is always the same: a closure is created inside a loop and called after the loop finishes.

Event handlers wired in a loop

The most common real bug is binding GUI or web callbacks for a set of widgets:

buttons = []
for label in ("save", "load", "quit"):
    btn = Button(text=label)
    btn.on_click(lambda: handle(label))   # BUG: every button handles "quit"
    buttons.append(btn)

Every button, when clicked, calls handle("quit") — the last value of label — because all three handlers close over the one label cell, and clicking happens long after the loop ended. The symptom is maddening to debug because the wiring looks correct and the handlers fire correctly; only the captured value is wrong, and identically wrong for every widget. The same shape appears with signal.connect(lambda: ...) in Qt, widget.bind("<Button>", lambda e: ...) in Tkinter, and route registration loops in web frameworks.

Deferred callbacks / scheduled tasks

Any API that stores a callable to run later is vulnerable:

tasks = []
for url in urls:
    tasks.append(lambda: fetch(url))     # all fetch the last url
# ... later ...
for t in tasks:
    t()                                   # every call fetches urls[-1]

Because the lambdas run after the loop, they all read the final url. This bites asyncio task lists, thread-pool submissions built in a loop, retry queues, and atexit.register(lambda: cleanup(resource)) loops. If the callables were instead called immediately inside the loop, there would be no bug — it is the deferral that exposes the shared cell.

functools.partial confusion

People who have heard “use partial to fix loop closures” sometimes misremember why it works and reach for the wrong tool. The distinction is crucial:

from functools import partial
 
# CORRECT: partial binds the VALUE of i eagerly into the bound arguments
handlers = [partial(handle, i) for i in range(3)]
[h() for h in handlers]               # calls handle(0), handle(1), handle(2)
 
# WRONG: a lambda wrapping partial re-introduces the free variable
handlers = [lambda: partial(handle, i)() for i in range(3)]   # back to [2,2,2] territory

partial(handle, i) works as a fix because partial is a function call: its argument i is evaluated immediately, at the moment partial(...) is constructed inside the loop, and the resulting value is stored in the partial’s .args. There is no deferred name lookup — the value 0, 1, 2 is captured eagerly. The confusion arises when someone wraps partial in a lambda or passes it a name to look up later; then the eager-capture property is lost and the trap returns. The mental model: partial captures by value (eagerly); a bare lambda captures by binding (lately).

How to Recognize It

The diagnostic signature is consistent across all the incarnations:

  1. A closure (lambda, inner def, partial-wrapping-lambda) is created inside a loop, and
  2. the closure references the loop variable (directly or via a name that resolves to it), and
  3. the closure is called after the loop completes.

If all three hold and the results are wrong, suspect late binding. The fastest confirmation is to print what each closure actually returns versus what you expected: a uniform “wrong” value equal to the loop’s last iteration is the giveaway. (Contrast the mutable-default trap, whose giveaway is accumulation; here the giveaway is uniformity — every closure agrees on the final value.) You can also inspect the closure’s cell directly: fns[0].__closure__[0].cell_contents shows the live value (2), and fns[0].__closure__[0] is fns[1].__closure__[0] returns True, proving the cell is shared.

The Fixes

All fixes share one strategy: break the shared-cell binding by capturing the per-iteration value eagerly, on the loop’s clock rather than the call’s clock. There are three idiomatic ways, plus the trivial non-fix of just not deferring.

Fix 1 — Default-argument capture (lambda i=i:)

The most compact fix exploits the other clock — default arguments, which are evaluated at function-definition time (see Default Arguments and Late Binding):

fns = [lambda i=i: i for i in range(3)]
[f() for f in fns]                # [0, 1, 2]   -- fixed

lambda i=i: i makes i a parameter with a default, and the default expression i (the loop variable) is evaluated as each lambda is created, once per iteration, snapshotting 0, 1, 2 onto each lambda’s own __defaults__. The body’s i is now a parameter, not a free variable, so it reads the frozen default, not the shared cell. The disassembly proof: the fixed lambda is built with SET_FUNCTION_ATTRIBUTE 1 (defaults) (not 8 (closure)), and its body is LOAD_FAST_BORROW 0 (i) — a plain local read, no LOAD_DEREF, no cell. Trade-offs: it is terse and needs no helper, but it pollutes the callable’s signature with a settable parameter (a caller can override it: f(99) returns 99), which is occasionally a real bug surface and always a slight readability cost. The repeated i=i also reads as a typo to the uninitiated. Despite the awkwardness, this is the most common fix in practice and the one Python’s own FAQ recommends (programming FAQ, “Why do lambdas defined in a loop with different values all return the same result?”).

Fix 2 — A factory function

Wrap the closure creation in a function whose parameter provides a fresh binding per call:

def make_fn(i):
    return lambda: i
 
fns = [make_fn(i) for i in range(3)]
[f() for f in fns]                # [0, 1, 2]

Each call to make_fn(i) creates a new frame with its own i parameter, and the returned lambda closes over that frame’s cell — a different cell per iteration. There is no sharing because each make_fn invocation has its own scope. Trade-offs: this is the most explicit and arguably the clearest fix — the per-iteration scope is visible — and unlike the default-argument trick it does not add an overridable parameter to the returned callable. The cost is verbosity (a named helper) and one extra function call per closure. It is the right choice when the closure is non-trivial or when you want the cleanest possible signature on the result.

Fix 3 — functools.partial

When the closure is simply “call this function with this captured value,” partial is the idiomatic tool:

from functools import partial
 
def handle(i):
    return i
 
fns = [partial(handle, i) for i in range(3)]
[f() for f in fns]                # [0, 1, 2]

As established above, partial(handle, i) evaluates i eagerly and stores the value in .args, so each partial holds its own snapshot (functools docs). Trade-offs: it is clean and self-documenting when the body is a single call, and the captured arguments are introspectable (p.args). It does not fit when the deferred work is more than one function call (you would need a real function anyway), and it requires the target to be a named callable taking the captured value as an argument. Use it for the “bind these arguments now, call later” shape; use a factory for anything more complex.

The non-fix that is sometimes the real fix

If the closures do not actually need to be deferred — if you can do the work inside the loop while the loop variable still holds the right value — then there is no trap to fix:

for i in range(3):
    process(i)        # i is correct here; no closure, no deferral, no bug

A surprising fraction of “loop closure bugs” are really “I deferred work that did not need deferring.” When restructuring to act eagerly is possible, it is the simplest correct code.

How Linters Catch It

Static analysis flags the dangerous shape — a closure that references a loop variable — before it runs.

  • Ruff / flake8-bugbear B023function-uses-loop-variable. Ruff’s B023 flags “function definitions that use a loop variable,” explaining that “the loop variable is not bound in the function definition, so it will always have the value it had in the last iteration when the function is called,” and its example shows [lambda x: x + i for i in range(3)] producing [3, 3, 3] (ruff B023). The recommended remedies it cites are exactly the default-argument-capture and partial fixes above.
  • Pylint W0640cell-var-from-loop. Pylint warns when a cell variable is defined inside a loop and captured by a closure — the message symbol is cell-var-from-loop, code W0640 (pylint W0640). It fires on the same pattern: a closure binding a name that the surrounding loop reassigns.

These checks have false positives (a closure that is called inside the loop, before the variable changes, is safe but may still be flagged), so they are warnings to inspect rather than hard errors. But in the deferred-callback case — the dangerous one — they are reliable. Enabling B023 in ruff plus pylint’s cell-var-from-loop catches the production-relevant instances at lint time.

Common Misunderstandings

  • “The lambda captured i’s value at creation.” No — it captured the variable (the cell). It reads the live cell when called. The value-at-creation is exactly what it did not capture, which is the whole bug.
  • “It’s a comprehension-inlining artifact in 3.12+.” No. Inlining changed only loop-variable leakage, not closure capture. [2,2,2] predates and postdates PEP 709.
  • partial works by magic / by binding late.” partial works because it evaluates its arguments eagerly at construction and stores the values. It is by-value capture, the opposite of the lambda’s by-binding capture.
  • lambda i=i: copies the value by value.” Python has no by-value parameters; the default snapshots the object reference at def time. For an immutable loop counter that is indistinguishable from a value copy. For a mutable captured object, x=x still shares the object — it only freezes which object, evaluated per lambda.
  • “This is a bug in Python.” It is late binding working as specified (execution model). The same mechanism powers legitimate closures like counters and decorators (see Cell Variables and Closures); you cannot remove the trap without removing closures.

See Also