PYTHON / ADVANCED PYTHON
Closures and late binding
Read a closure's cell contents, predict late-binding results in loops, and fix them with default args, factories, or nonlocal.
What you will learn
- Inspect a closure with __code__.co_freevars and __closure__[i].cell_contents
- Predict why loop-created lambdas all return the final loop value
- Bind a value at definition time with a default parameter or a factory function
- Rebind an enclosing variable with nonlocal instead of triggering UnboundLocalError
Understanding Closures and late binding
When a nested function mentions a name that belongs to an enclosing function, the compiler does not copy that name's value into the inner function. It marks the name as a free variable and puts it in a shared cell object, a one-slot container that both the outer frame and the inner function point at. You can see the wiring directly: the inner function's __code__.co_freevars lists the names, and __closure__ holds the matching cells, each with a .cell_contents attribute. A function with no free variables has __closure__ set to None.
Late binding is the direct consequence of storing a reference to a cell rather than a value: the cell is read at call time, not at def time. A for loop does not create a new variable per iteration, it rebinds one variable, so every function created in the loop body ends up sharing the same cell. Call them after the loop and they all report whatever the loop left behind. Nothing has been overwritten or lost, they simply all ask the same question at the same later moment.
To capture a value instead of a cell, evaluate it while the def is executing. A default parameter does exactly this, since defaults are evaluated once at function definition, which is why `lambda x, n=n: x * n` has no free variables at all. Calling a factory function per iteration works too, because each call creates a fresh frame and therefore a fresh cell. Going the other direction, if the inner function assigns to the name, Python treats it as local and the closure disappears, which is what `nonlocal` exists to prevent.
def make_multipliers():
funcs = []
for n in (2, 3, 4):
funcs.append(lambda x: x * n)
return funcs
def make_multipliers_fixed():
funcs = []
for n in (2, 3, 4):
funcs.append(lambda x, n=n: x * n)
return funcs
late = make_multipliers()
print([f(10) for f in late])
early = make_multipliers_fixed()
print([f(10) for f in early])
f = late[0]
print(f.__code__.co_freevars, f.__closure__[0].cell_contents)
g = early[0]
print(g.__code__.co_freevars, g.__closure__)A closure holds a reference to the enclosing variable's cell, not a snapshot of its value, so the value is looked up when the inner function runs.
Worked examples
Shared cells and nonlocal
Two closures over the same variable share one cell, and assigning without nonlocal destroys the closure entirely.
def make_counter(start=0):
count = start
def increment(step=1):
nonlocal count
count += step
return count
def peek():
return count
return increment, peek
inc, peek = make_counter(10)
print(inc(), inc(5), peek())
print(inc.__closure__[0] is peek.__closure__[0])
def broken():
total = 0
def add(x):
total = total + x
return total
return add
try:
broken()(1)
except UnboundLocalError:
print("UnboundLocalError: the assignment made 'total' local")Example explained
Line 1print(inc(), inc(5), peek()) evaluates arguments left to right, so count goes 11 then 16 and peek reports 16.
Line 2increment and peek were created in the same call to make_counter, so their closures point at the identical cell object, which is why `is` returns True.
Line 3In broken, `total = total + x` makes total a local name of add, so the enclosing total is no longer a free variable at all.
Line 4Reading a local before it has been assigned raises UnboundLocalError; adding `nonlocal total` would restore the closure and fix it.
Important notes
A comprehension has its own scope, but still only one binding of the loop variable, so `[lambda: i for i in range(3)]` produces three functions that all return 2.
The default-argument fix changes the public signature; if that matters, use functools.partial or an explicit factory function instead.
Common mistakes
Registering callbacks in a loop with `lambda: handle(item)` and expecting each to see its own item; every callback fires with the last item instead.
Reading `count += 1` inside a nested function without nonlocal, which silently makes count local and raises UnboundLocalError on the first call.
Using the `n=n` default trick on a function that callers invoke with positional arguments, so a caller accidentally overwrites the captured value.
Try it yourself
Change, predict, then run
Write a function that returns a list of three functions, each of which should print its own index when called. First write it so all three print 2, then fix it two different ways: once with a default parameter and once with a helper factory function.
Open the Python workspaceCheck your understanding
A function appends `lambda: n` for each n in [1, 2, 3], then executes `n = 99` after the loop and returns the list. What do the three lambdas return when called afterwards?
- 1, 2, 3 — each lambda captured the value of n when it was created
- 3, 3, 3 — the closure freezes on the value n had when the loop ended
- 99, 99, 99 — all three read the same cell at call time
- UnboundLocalError, because n was rebound after the closures were created
Show answer
All three lambdas share one cell for n and read it when called, so they see the most recent assignment, 99. The tempting answer is 3, 3, 3, which assumes the closure snapshots the value as the loop exits; nothing is captured at that moment, so the later `n = 99` is visible too.