Back to Blog
Python

Python Closure Late Binding: Loop Variable Capture

python closure late binding: Understand why Python closures capture variables by reference, not value, and how to fix the classic loop variable capture problem.

closureslambdascopingdefault-argumentsfunctools-partialpython-internals
Illustration of a Python closure capturing a variable by reference, with three closures pointing to the same final loop value.

Why Python Closures Capture Variables by Reference

Python closure late binding is the behavior where an inner function captures a reference to an outer variable rather than a snapshot of its value. When you create a closure, the lookup of the captured variable happens at call time, not at definition time.

def make_multiplier(factor): def multiply(x): return x * factor return multiply double = make_multiplier(2) print(double(5)) # 10

At first glance this looks like double remembers that factor was 2. But what it actually remembers is the cell that holds factor. If factor changes after make_multiplier returns, double will see the new value.

def make_counter(): count = 0 def increment(): nonlocal count count += 1 return count return increment counter = make_counter() print(counter()) # 1 print(counter()) # 2

This is the same mechanism. The closure holds a reference to count, and the nonlocal declaration allows the inner function to mutate it. Late binding is not a bug; it is the design that makes stateful closures like this counter possible.

The Classic Loop Variable Capture Problem

The most common place developers hit late binding is when they create closures inside a loop.

functions = [] for i in range(3): functions.append(lambda: i) for f in functions: print(f()) # 2, 2, 2

All three lambdas print 2. The reason is that each lambda captures the same loop variable i. By the time the second loop runs, i has finished at 2, so every closure reads 2.

The same problem appears with list comprehensions:

functions = [lambda: i for i in range(3)] for f in functions: print(f()) # 2, 2, 2

And with nested function definitions in loops:

functions = [] for i in range(3): def f(): return i functions.append(f) for f in functions: print(f()) # 2, 2, 2

The pattern is identical regardless of whether you use lambda or a def statement. The closure captures the variable, not the value.

Why the Loop Variable Is Shared

In Python, a for loop does not create a new scope. The loop variable i lives in the enclosing function scope (or module scope if the loop is at module level). Every iteration assigns a new value to the same variable. When you create a closure inside the loop body, that closure captures the variable itself, not the value from the current iteration.

This is different from languages like JavaScript with let, where each iteration of a for loop creates a fresh binding. Python has no such per-iteration binding for ordinary loops.

The closure captures what Python calls a cell variable. The compiler detects that i is referenced by an inner function and stores it in a cell object. All closures created in the loop share that same cell. When the loop finishes, the cell holds the final value, and every closure reads that final value.

Fixing Late Binding with Default Arguments

The classic fix is to bind the current value as a default argument. Default arguments are evaluated once, at function definition time, so they capture the value rather than the variable.

functions = [] for i in range(3): functions.append(lambda i=i: i) for f in functions: print(f()) # 0, 1, 2

The i=i default argument evaluates i immediately when the lambda is created. The lambda now has a local parameter named i whose default is the value from that iteration. When called with no arguments, it returns that captured default.

The same fix works with def:

functions = [] for i in range(3): def f(i=i): return i functions.append(f) for f in functions: print(f()) # 0, 1, 2

This works because default arguments are stored on the function object and evaluated at definition time. The closure no longer needs to look up the outer i at call time.

One caveat: the parameter name shadows the outer variable. If the closure body needs to refer to the outer variable for some other purpose, you need a different approach. In practice, this is rarely an issue because the whole point is to capture the current value.

Using a Factory Function to Create a Fresh Scope

Another fix is to move the closure creation into a separate function call. Each call to the factory creates a new local variable, and the closure captures that local variable.

def make_printer(value): def printer(): return value return printer functions = [make_printer(i) for i in range(3)] for f in functions: print(f()) # 0, 1, 2

Each call to make_printer(i) creates a new local variable value in its own stack frame. The closure captures that specific local variable, which never changes after the factory returns. This is arguably cleaner than the default-argument trick because it does not introduce a parameter that shadows the outer name.

The factory approach also works when the closure needs to mutate a captured value without affecting other closures:

def make_counter(start): count = start def increment(): nonlocal count count += 1 return count return increment c1 = make_counter(0) c2 = make_counter(100) print(c1()) # 1 print(c2()) # 101

Each closure has its own count cell because each call to make_counter creates a fresh local variable.

Using functools.partial for Callable Objects

If the closure is simply a function call with fixed arguments, functools.partial can replace the lambda entirely. partial stores the arguments at creation time, so it does not exhibit late binding.

from functools import partial def print_value(x): return x functions = [partial(print_value, i) for i in range(3)] for f in functions: print(f()) # 0, 1, 2

partial is useful when you already have a named function and only need to bind some of its arguments. It is also slightly more readable than a lambda with a default argument, because the intent is explicit: bind i now.

The tradeoff is that partial only works when the closure is a call to a specific function. If the closure needs to compute something more complex, a lambda or a nested function is more appropriate.

When Late Binding Is What You Want

Late binding is not always a problem. Sometimes you want the closure to read the current value of a variable at call time.

config = {"retries": 3} def retry(operation): def wrapper(*args, **kwargs): for _ in range(config["retries"]): try: return operation(*args, **kwargs) except Exception: pass return wrapper

The wrapper reads config["retries"] each time it runs. If you update the config after creating the wrapper, the new value takes effect. This is late binding working in your favor: the closure stays in sync with the current configuration.

A more deliberate use is a callback that reads a mutable default from an enclosing scope:

state = {"count": 0} def increment_state(): state["count"] += 1 def report(): return state["count"]

Here report deliberately reads the current state at call time. If you replaced this with a default-argument capture, report would return a stale value after state changes.

Performance and Memory Implications of Cell Variables

Cell variables have a small runtime cost. Every access to a captured variable goes through the cell object rather than a direct local variable lookup. In tight loops, this can be measurably slower than accessing a plain local.

def with_cell(): x = 10 def inner(): return x return inner def with_local(): x = 10 return x

The inner function in with_cell must dereference the cell to read x. The function in with_local reads a local directly. For most code the difference is negligible, but for hot paths that call the closure millions of times, it can matter.

Memory-wise, a cell keeps the captured variable alive as long as any closure references it. If a closure captures a large object and outlives the scope where it was created, that object cannot be garbage collected until the closure is dropped. This is usually fine, but be aware of it when closures capture large data structures or file handles.

The default-argument fix does not change this fundamentally. The default value is stored on the function object, so it is also kept alive by the function. The difference is that the default value is a snapshot, not a live reference to a mutable cell.

Compatibility Notes Across Python Versions

The late binding behavior has been consistent across Python 2 and Python 3. The default-argument trick and the factory-function approach work in both. functools.partial is available in both as well.

One version-related difference: Python 3 list comprehensions have their own scope, but they still share the loop variable across iterations within the comprehension. So [lambda: i for i in range(3)] still produces three closures that all see the final i. This is not a bug in the comprehension; it is the same late binding behavior applied to the comprehension's internal variable.

If you are porting code from Python 2 to Python 3, the closure behavior itself does not change. What may change is the scope of comprehension variables, which can affect whether a name is captured at all. In Python 2, a list comprehension leaked its variable into the enclosing scope. In Python 3, it does not. That changes which variable a closure captures, but the late binding rule itself is unchanged.

python closure late binding: Practical Usage and Code Exampl | RYUSLOG DEV