Back to Blog
Python

Python Lambda Late Binding: The Loop Trap

python lambda late binding: Learn why Python lambdas capture loop variables by reference and how to fix late binding with default arguments or functools.partial.

lambdaclosuresscopepythonfunctools
Illustration of a Python lambda closure capturing a loop variable by reference, showing the late binding trap.

If you have ever built a list of lambdas inside a loop and called them later, you have likely seen every function return the same value. This is the classic python lambda late binding problem: the lambda captures the variable itself, not its value at definition time. When the loop finishes, all lambdas see the final value of the loop variable.

The Classic Late Binding Problem

Consider a simple loop that creates three functions, each intended to return a different number:

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

You might expect the output to be 0, 1, 2. Instead, it is 2, 2, 2. The reason is that i is a local variable in the enclosing scope, and each lambda refers to that same variable. By the time you call the functions, the loop has completed and i holds its last value, 2. This behavior is not a bug in Python; it is a direct consequence of how closures work.

Why Lambdas Capture by Reference

In Python, a closure captures variables from the enclosing scope by reference, not by value. When you write lambda: i, the lambda stores a reference to the i cell, not a snapshot of the integer. As long as the variable exists in the enclosing scope, the lambda reads the current value at call time. This is called late binding.

The same mechanism applies to normal nested functions. The difference is that lambdas are often used inline in loops, making the issue more visible. The variable i is reused across iterations; it is not a new variable each time. In Python, loop variables are not block-scoped. They belong to the enclosing function or module scope.

A Minimal Reproduction

You can reproduce the behavior with a list comprehension as well, though the scope rules differ slightly. In Python 3, the comprehension variable is local to the comprehension, but lambdas inside it still capture that variable by reference:

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

This prints [2, 2, 2] because x is a single variable that is rebound in each iteration. The lambda captures the cell, not the value. Understanding this minimal case helps when debugging more complex code.

Fixing with Default Arguments

One common fix is to bind the current value as a default argument. Default arguments are evaluated at function definition time, so they capture the value immediately:

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

Now the output is 0, 1, 2. The parameter i shadows the outer variable, and the default value is fixed when the lambda is created. This works because the default is evaluated once, at definition time. The lambda no longer depends on the outer i.

This approach is simple and does not require any imports. It is the most direct way to force early binding in a lambda.

Using functools.partial

Another approach is to use functools.partial to create a callable that holds a fixed argument. This is more explicit and can be easier to read when the lambda would have multiple parameters:

from functools import partial def make_func(value): return partial(lambda v: v, value) funcs = [make_func(i) for i in range(3)] for f in funcs: print(f())

Here, partial stores the value and passes it to the lambda when called. The lambda itself does not capture the loop variable; it only receives the value as an argument. This pattern is useful when you need to pass additional arguments later or when the function signature is more complex.

Choosing a Fix Based on Context

The default-argument trick is the most common and works well for simple cases. It is concise and does not add extra function calls. However, it can be confusing to readers who do not know the idiom. functools.partial is more explicit and often preferred in codebases where readability is a priority.

ApproachEvaluation timeReadabilityExtra importBest for
Default argumentDefinitionModerateNoSimple lambdas with few parameters
functools.partialDefinitionHighYesComplex signatures, explicit code

Use the default argument when you want a one-line fix and the lambda is short. Use partial when you need to keep the function signature clear or when the lambda already has parameters that must be preserved.

Runtime and Maintainability Tradeoffs

Both fixes have negligible runtime cost. The default argument creates a new integer object for each lambda, which is already the case for the loop variable. partial creates a small object that wraps the callable, adding one extra function call per invocation. In most applications this difference is irrelevant.

From a maintainability perspective, the default-argument idiom is often considered a trick. It relies on the reader knowing that defaults are evaluated at definition time. If the code is part of a larger codebase, adding a comment or extracting a factory function can improve clarity. For example, you can define a factory that returns a lambda with the value bound explicitly:

def make_printer(value): return lambda: print(value) funcs = [make_printer(i) for i in range(3)]

This avoids the late binding issue entirely and is self-documenting. The factory function makes the intent clear and is easier to test.

Late Binding Beyond Loops

Late binding is not limited to loops. It also appears in decorators, callbacks, and any closure that references a variable that changes later. For example, a closure that uses a mutable default argument can exhibit similar surprises. The same principle applies: the closure sees the variable's current value at call time, not the value at creation time.

When you encounter unexpected behavior in lambdas, check whether the lambda captures a variable that is reassigned after the lambda is defined. If so, apply one of the binding techniques described above. Understanding late binding helps you write predictable closures and avoid subtle bugs in event handlers, GUI callbacks, and asynchronous code.

python lambda late binding: Practical Usage and Code Example | RYUSLOG DEV