What is a closure, and what's the late-binding pitfall with closures in loops?

6 minintermediatefunctionsclosuresgotcha

Quick Answer

A closure is a nested function that remembers variables from its enclosing scope even after that scope has finished executing, by keeping a reference to the enclosing variable (a "cell"), not a snapshot of its value at creation time. The classic pitfall: closures created in a loop (e.g., a list of lambdas) all share the same loop variable. By the time they're called, they all see its final value, not the value at the time each closure was created.

Detailed Answer

What makes something a closure

def make_multiplier(factor):
    def multiply(x):
        return x * factor       # `factor` is captured from the enclosing scope
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)
double(5)   # 10
triple(5)   # 15

multiply is a closure over factor. Even after make_multiplier returns, multiply still has access to its own factor, because Python keeps the enclosing variable alive as long as any closure references it. double and triple each capture a different factor because each call to make_multiplier creates a fresh scope.

The late-binding trap

funcs = []
for i in range(3):
    funcs.append(lambda: i)

[f() for f in funcs]   # [2, 2, 2]  -- NOT [0, 1, 2]!

Every lambda captures the variable i by reference, not its value at the time the lambda was created. All three lambdas share the same enclosing scope, since the loop body doesn't create a new scope per iteration (loops don't introduce scopes in Python at all). By the time they're called, i has already finished the loop and holds its final value, 2.

The fix: bind the value via a default argument

funcs = []
for i in range(3):
    funcs.append(lambda i=i: i)   # default arg is evaluated NOW, at def-time

[f() for f in funcs]   # [0, 1, 2]

Default argument values are evaluated once, when the lambda/def is created. Using i=i copies the current value of i into the function's own default, decoupling it from the loop variable's later mutations.

Alternative fix: a factory function

def make_getter(value):
    return lambda: value    # closes over `value`, a fresh parameter per call

funcs = [make_getter(i) for i in range(3)]
[f() for f in funcs]   # [0, 1, 2]

Each call to make_getter creates a genuinely new scope with its own value, so each returned lambda captures a distinct variable. This is the same principle as make_multiplier above, just applied to fix the loop pitfall.

Closures capture variables by reference to the enclosing scope, not by value. This is powerful for building factories/decorators, but it means closures created in a loop all share the loop variable and see its final value, unless you force early binding (the default argument trick or a separate factory function per iteration).