Backend / Python core / Tricky questions / 02_late_binding_closures.md

Late-binding closures

Updated 3 interview angles 2 min read source
On this page6
  1. What is a closure
  2. The gotcha
  3. Minimal repro
  4. Why it happens
  5. How to avoid
  6. Interview angle

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.

python
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__:

python
times3.__closure__[0].cell_contents   # 3

Closures 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

python
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):

python
funcs = [lambda i=i: i for i in range(3)]
print([f() for f in funcs])   # [0, 1, 2]

Or use functools.partial:

python
from functools import partial
funcs = [partial(lambda x: x, i) for i in range(3)]

Or wrap in an immediate function:

python
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 returns 2. Closures capture the variable, not its value at creation time, so they all read the same i after 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 takes i as 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.