Back to Blog
Python

Python Late Binding Closure: How It Works

Understand why Python closures capture variables late and how to bind the correct value in loops and lambdas.

closureslate bindingPython scopinglambdadefault arguments
Diagram showing a closure capturing a loop variable late, with a binding arrow pointing to the final value.

Python resolves variables captured by closures at call time, not at definition time. That behavior is called late binding. It often surprises developers who create lambdas inside a loop, because every lambda ends up using the final value of the loop variable. The examples below show why this happens and how to bind the intended value.

The Late Binding Problem in a Nutshell

Consider this common pattern, wrapped in a small factory function so the lambdas are genuine closures:

def make_funcs(): funcs = [] for i in range(3): funcs.append(lambda: i) return funcs funcs = make_funcs() for f in funcs: print(f())

Writing the same loop at module level would produce the same output, because i would be a global looked up when each lambda runs. The factory version makes the closure behavior explicit.

You might expect 0 1 2, but you get 2 2 2. This is the classic late binding issue in Python closures. The lambda does not capture the literal value of i at definition time. Instead, it captures a cell object that the enclosing function updates as the loop runs. By the time the returned lambdas are called, the loop has finished and the cell holds i's final value.

This behavior is not a bug; it is how Python's scoping rules work. Understanding why it happens and how to control it is essential for writing predictable closures.

How Python Resolves Names in Closures

When you define a nested function that references a variable from an enclosing function, Python stores that variable in a cell object shared between the enclosing and nested scopes. The nested function does not copy the value at definition time. Instead, it looks up the cell's current value each time the function is called.

This is known as late binding: the value stored in the cell is looked up when the function runs, not when it is defined. The same mechanism applies to lambdas, which are just anonymous functions. Any variable from an outer scope that is referenced inside the lambda is resolved through the closure.

The Loop Variable Trap

The most common place this bites developers is in loops that generate callbacks or lambdas. In a function, the for loop variable is a single local variable that is reassigned on each iteration. All lambdas created in the loop close over that same variable.

def make_handlers(): handlers = [] for key in ('a', 'b', 'c'): handlers.append(lambda: print(key)) return handlers handlers = make_handlers() for h in handlers: h() # prints 'c' three times

The same problem occurs with list comprehensions, but there is a subtle difference. In Python 3, the comprehension variable is scoped to the comprehension, so it does not leak. However, lambdas inside a comprehension still close over the comprehension's variable, and that variable is also reused across iterations.

funcs = [lambda: x for x in range(3)] for f in funcs: print(f()) # prints 2 2 2

The comprehension variable x is still a single cell that is updated each iteration.

Binding Values with Default Arguments

The simplest fix is to use a default argument to capture the current value. Default arguments are evaluated at function definition time, so they bind the value immediately.

def make_funcs(): funcs = [] for i in range(3): funcs.append(lambda i=i: i) return funcs funcs = make_funcs() for f in funcs: print(f()) # prints 0 1 2

Here, i=i creates a local parameter i whose default value is the current value of the loop variable. The lambda no longer needs to close over the outer i; it has its own local binding. This is a concise and idiomatic solution, though it can be confusing to readers who are not familiar with the trick.

Using functools.partial to Capture Values

Another approach is to use functools.partial to pre-fill an argument. This makes the intent explicit: you are creating a callable with a fixed argument.

from functools import partial def show(x): print(x) def make_show_partials(): funcs = [] for i in range(3): funcs.append(partial(show, i)) return funcs funcs = make_show_partials() for f in funcs: f() # prints 0 1 2

partial returns a new callable that stores the argument and passes it to the original function when called. In this pattern, it avoids the closure entirely because the value is stored as an attribute of the partial object. It is more readable than the default-argument trick, especially when the function already takes parameters.

Creating a Factory Function to Avoid Late Binding

A third option is to use a factory function that takes the value as a parameter and returns a closure. Each call to the factory creates a new local scope, so the value is stored in its own cell for each returned closure.

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

This is the most explicit and often the clearest approach. The factory makes it obvious that each closure gets its own binding. It also gives you a place to add additional configuration if needed.

When Late Binding Is the Desired Behavior

Late binding is not always a mistake. Sometimes you want a closure to read the current value of a shared object. For example, a callback that should reflect the latest state of a configuration object:

def make_tracker(): state = {'count': 0} def increment(): state['count'] += 1 def report(): print(state['count']) return increment, report increment, report = make_tracker() increment() increment() report() # 2

Here, report closes over state, and every call sees the latest value because state is the same dictionary object. This is useful for event handlers or observers that need to access shared state. The key is to be deliberate about which variables you intend to bind late.

Runtime and Maintainability Considerations

For these examples, all three fixes produce the same output. The performance differences are negligible for typical use cases. The real tradeoff is readability and maintainability.

The default-argument trick is compact but can puzzle developers who do not know the pattern. functools.partial is more explicit and works well when the underlying function is already defined. A factory function is the most verbose but also the most self-documenting.

ApproachReadabilityExplicit bindingBest for
Default argumentModerateImplicitSimple lambdas
functools.partialGoodExplicitNamed functions
Factory functionHighExplicitComplex closures

When you encounter late binding in a codebase, the best fix depends on context. If the function is a one-liner lambda, a default argument is often sufficient. If the function has a name and multiple parameters, partial may be clearer. If the closure needs to carry additional state, a factory is the right choice.

The key is to understand that Python closures capture variables, not values. Once you internalize that, you can predict when late binding will occur and choose the appropriate binding strategy.

Python Late Binding Closure: Fixes and Examples | RYUSLOG DEV