Ch. 9 · Python

Python Closures and Late Binding

Why lambdas created in a loop all return the last value, what a closure cell really stores, and the three clean ways to capture a value.

~5 min readintermediateupdated Oct 7, 2026

Python looks up a free variable when the inner function runs, not when it’s defined. That’s late binding, and it’s behind one of the most common Python interview puzzles: a loop builds a list of lambdas, and calling them all returns the same answer. Once you see what a closure actually holds, the puzzle stops being a trick and becomes a rule you can apply to callbacks, event handlers and decorators.

The loop of lambdas

Here’s the classic version, wrapped in a function so the lambdas are real closures:

def build_handlers():
    handlers = []
    for name in ["save", "load", "quit"]:
        handlers.append(lambda: f"clicked {name}")
    return handlers

hs = build_handlers()
print([h() for h in hs])
print(hs[0].__code__.co_freevars)
print(hs[0].__closure__[0] is hs[2].__closure__[0])
print(hs[0].__closure__[0].cell_contents)
python
['clicked quit', 'clicked quit', 'clicked quit']
('name',)
True
quit
Text

Most people expect save, load, quit. The inspection lines explain why that’s wrong. co_freevars shows the lambda refers to a free variable called name. __closure__ holds a cell for that variable, and the is check proves all three lambdas share the very same cell. A cell is a small box that the enclosing function and its inner functions all point to. The for loop rebinds name three times, which updates what’s in the box. When you finally call the lambdas, the box contains "quit".

So the closure doesn’t remember a value. It remembers where to look. A common wrong explanation in interviews is “the lambda captures name by reference.” That’s close but muddled: Python has no references-to-variables in the C++ sense, and the shared object here isn’t the string, it’s the cell holding the binding.

When you’re debugging a callback that returns the wrong value, you don’t have to guess. inspect.getclosurevars(hs[0]) prints ClosureVars(nonlocals={'name': 'quit'}, globals={}, builtins={}, unbound=set()), which tells you in one line which outer names the function reads and what they hold right now. On the other side, build_handlers.__code__.co_cellvars is ('name',): the compiler noticed that an inner function reads name and stored that variable in a cell instead of an ordinary local slot. That decision happens at compile time, once per function, which is why there’s one cell for the whole loop rather than one per iteration.

If you run the same loop at module level, there’s no closure at all: __closure__ is None and name is simply a global looked up at call time. The output is identical, because a global is also read when the function runs. Late binding applies to globals and closures alike:

limit = 10
def check(x):
    return x < limit
limit = 3
print(check(5))
python
False
Text

Three ways to capture the current value

Each fix works by making a new binding per iteration instead of sharing one.

A factory function. Each call to make_handler creates a fresh local name, so each inner function gets its own cell.

def make_handler(name):
    def handler():
        return f"clicked {name}"
    return handler

handlers = [make_handler(n) for n in ["save", "load", "quit"]]
print([h() for h in handlers])
python
['clicked save', 'clicked load', 'clicked quit']
Text

This is the clearest version and the one to reach for in production code, because the intent (“make a handler for this name”) is the function name.

functools.partial. It stores the argument on the partial object at creation time.

from functools import partial

def on_click(name):
    return f"clicked {name}"

handlers = [partial(on_click, n) for n in ["save", "load", "quit"]]
print([h() for h in handlers])
python
['clicked save', 'clicked load', 'clicked quit']
Text

It’s compact and shows up well in debuggers, since repr displays the bound arguments.

A default argument. Defaults are evaluated once, when the def or lambda runs, which is exactly when you want the snapshot.

handlers = [lambda name=name: f"clicked {name}" for name in ["save", "load", "quit"]]
print([h() for h in handlers])
print(handlers[0]("oops"))
python
['clicked save', 'clicked load', 'clicked quit']
clicked oops
Text

It works, and you’ll see it in a lot of GUI code, but the last line shows the cost: the captured value is now a public parameter that a caller can override by accident. It also confuses readers who don’t know the idiom. Mention that trade-off in an interview and you’ll sound like someone who has maintained this kind of code.

Notice that a list comprehension alone doesn’t fix anything. In the default-argument version the comprehension still shares one name variable across iterations; the default argument is what freezes each value. The comprehension’s own scope only keeps name from leaking out.

Rebinding: nonlocal and the UnboundLocalError surprise

Reading an enclosing variable is free. Assigning to it is different. Python decides at compile time that any name assigned inside a function is local to that function, unless told otherwise.

def broken_counter():
    count = 0
    def inc():
        count += 1
        return count
    return inc

broken_counter()()
python
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value
Text

Because inc assigns to count, the compiler treats count as a local of inc, and count += 1 tries to read that local before it exists. The fix is nonlocal:

def counter():
    count = 0
    def inc():
        nonlocal count
        count += 1
        return count
    return inc

c = counter()
print(c(), c(), c())
python
1 2 3
Text

nonlocal count tells the compiler to use the enclosing function’s cell. Mutating a shared object (items.append(x)) never needs nonlocal, because that doesn’t rebind the name, a distinction that comes up in follow-up questions. The UnboundLocalError guide covers the global-variable version of the same rule.

Where late binding bites in real code

The lambda puzzle looks artificial, but the same pattern appears in places that matter:

  • GUI and event callbacks. button.config(command=lambda: open_file(path)) inside a loop makes every button open the last file.
  • Deferred work. Scheduling loop.call_later(1, lambda: process(item)) for each item in a batch processes the last item repeatedly.
  • Test parametrisation by hand. Building test functions in a loop and attaching them to a class gives every test the final parameter.
  • Threads. Thread(target=lambda: worker(i)) in a loop can pass whatever i happens to be when the thread starts, which may differ run to run. Pass args=(i,) instead.

In each case, the question is the same: does the function need the value now, or the variable later? Usually it’s the value.

Interview practice: predict the output

What does this print, and how would you change it to print 0 2 4?

def multipliers():
    return [lambda x: i * x for i in range(3)]

print(*[m(2) for m in multipliers()])
python

Answer

It prints 4 4 4. The comprehension has its own scope with a single variable i, and all three lambdas close over that one cell. By the time any lambda runs, the comprehension has finished and i is 2, so each call returns 2 * 2. To get 0 2 4, give each lambda its own binding: [lambda x, i=i: i * x for i in range(3)] works, [partial(operator.mul, i) for i in range(3)] is clearer and avoids the overridable parameter. A good answer also says why the comprehension doesn’t help on its own: it isolates i from the surrounding function, but inside the comprehension there’s still one variable rebound three times.

Keep going

More in Python

read ✓Python · hard

Python asyncio.gather and Timeouts

What asyncio.gather does when one task fails or a deadline passes, and when to use return_exceptions, asyncio.timeout, wait or TaskGroup.

~7 min readread →
esc