Late-binding closures
What is a closure
A closure is a nested function that captures and remembers variables from its enclosing scope, even after that scope has finished executing. The inner function keeps the outer function’s locals alive.
def multiplier(n):
def multiply(x): # inner function reads `n` from the enclosing scope
return x * n # `n` is a *free variable* — not a param, not a global
return multiply # returned with `n` still bound
times3 = multiplier(3)
# 30 — `n=3` survived after multiplier() returned
times3(10)Three ingredients: a nested function, a free variable it reads from the enclosing scope, and the outer function returning the inner one. Python stores each captured variable in a cell, visible via __closure__:
times3.__closure__[0].cell_contents # 3Closures are how decorators, factories, and callbacks carry state without a class. The catch is how they capture — by variable, not by value — which is the gotcha below.
The gotcha
Closures capture variables, not values. The lookup happens when the closure is called, not when it’s created.
Minimal repro
funcs = [lambda: i for i in range(3)]
# [2, 2, 2] ← all capture the same `i`
print([f() for f in funcs])The classic loop-variable trap. Same with for i in range(3): funcs.append(lambda: i).
Why it happens
Each lambda has a closure over the enclosing scope’s name i. By the time the lambdas run, the loop has finished and i is bound to the last value, 2. Python doesn’t snapshot i at lambda creation.
How to avoid
Pin via default argument (default args ARE evaluated eagerly, see 01_mutable_default_arguments.md):
funcs = [lambda i=i: i for i in range(3)]
print([f() for f in funcs]) # [0, 1, 2]Or use functools.partial:
from functools import partial
funcs = [partial(lambda x: x, i) for i in range(3)]Or wrap in an immediate function:
def make(i):
return lambda: i
funcs = [make(i) for i in range(3)]Interview angle 3
- “What does this list of lambdas return?” - given
[lambda: i for i in range(3)], every one returns2. Closures capture the variable, not its value at creation time, so they all read the sameiafter the loop finished. - “How do you fix it?” - bind the value at definition with a default
argument,
lambda i=i: i, or build the function in a factory that takesias a parameter. Both create a new binding per iteration instead of sharing one. - “Where does this bite in real code?” - callbacks registered in a loop:
for btn in buttons: btn.on_click(lambda: handle(btn))wires every button to the last one. Same bug, and much harder to spot because nothing prints a suspicious list.