One of the most common and perplexing issues encountered by Python programmers when first working with closures is the late-binding behavior of closures. This behavior often leads to unexpected results, especially when the closure is created inside a loop. Understanding this mechanism is crucial for writing correct and predictable code.

The Problem: Unexpected Closure Values

Consider a scenario where you want to create a list of simple functions, each of which returns a number when called. You might try to create them in a loop.

functions = []
for i in range(3):
    def my_func():
        return i
    functions.append(my_func)

# Now let's call each function
for f in functions:
    print(f())

The intuitive expectation is that this code will print:

0
1
2

However, the actual output is:

2
2
2

This is the classic manifestation of the late-binding closure gotcha. All three functions return the same value, 2, which was the final value of i when the loop ended.

Why Late Binding Occurs

The reason for this behavior lies in how Python’s name binding and scoping work. When the inner function my_func is defined, it does not capture the current value of the variable i at that moment. Instead, it captures a reference to the variable i itself from the enclosing scope.

The key insight is that there is only one variable i for the entire duration of the loop. Each iteration reassigns this single variable to the next value (0, then 1, then 2). The three different function objects you create all hold a closure that points to this exact same variable.

When you later call any of these functions, the interpreter looks up the current value of the variable i that the function’s closure refers to. By the time the loop has finished and you call the functions, the variable i has been set to its final value, 2. Therefore, every function call returns 2.

This is termed “late binding” because the value of i is not looked up until the function is actually called, not when it is defined.

Solution 1: Default Argument Binding

The most common and Pythonic solution to this problem is to use default arguments. Default arguments are evaluated at the time the function is defined, not when it is called. This allows you to capture the value of the loop variable at the moment of each function’s creation.

functions = []
for i in range(3):
    def my_func(x=i):  # 'i' is evaluated here and its value is bound to x
        return x
    functions.append(my_func)

for f in functions:
    print(f())  # Output: 0, 1, 2

You can make the function signature cleaner by using the captured value directly as the default for the parameter you intend to use.

functions = []
for i in range(3):
    def my_func(i=i):  # The parameter 'i' shadows the outer 'i'
        return i
    functions.append(my_func)

for f in functions:
    print(f())  # Output: 0, 1, 2

Solution 2: Using functools.partial

Another effective approach is to use functools.partial to create a new function with some arguments pre-filled. This also freezes the value at the time of creation.

from functools import partial

functions = []
for i in range(3):
    def power(exponent, base):
        return base ** exponent
    # Create a new function where 'base' is fixed to the current i
    functions.append(partial(power, i))

# functions[0] is now partial(power, 0)
# functions[1] is now partial(power, 1)
print(functions[2](2))  # Calls power(2, 2) -> 4

Solution 3: Creating a New Scope

You can force the creation of a new variable binding for each iteration by wrapping the function creation inside another function. This creates a new scope for each iteration.

functions = []
for i in range(3):
    def make_func(n):
        # This 'n' is a local variable in make_func's scope,
        # created anew for each call to make_func.
        def new_func():
            return n
        return new_func
    # Append the result of calling make_func with the current i
    functions.append(make_func(i))

for f in functions:
    print(f())  # Output: 0, 1, 2

In this pattern, the value of the loop variable i is passed as an argument to make_func. The parameter n is a local variable within the scope of each call to make_func. Each inner function new_func closes over its own specific n variable, which is independent and holds the value from that specific iteration.

Best Practices and Key Takeaway

The late-binding closure behavior is not a bug but a fundamental design choice in Python. The best practice is to always be cautious when defining closures that reference variables from a loop or any scope that changes. The default argument method (arg=value) is generally the most straightforward and readable solution for this specific problem. By understanding that closures capture variables by reference, not by value, you can write more intentional and less error-prone code.