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, so 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. Since all three lambdas share the same enclosing scope (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.

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