Late Binding Closures — Why lambda in a Loop Breaks
Python closures are like a camera pointed at a variable name, not at its value. When you later look at the photo (execute the function), you see whatever is in front of the camera now (the current value of the variable), not what was there when you pressed the shutter.
The Setup
You are creating a routing table for an event dispatcher that registers several callback functions dynamically using a loop. Each callback is supposed to multiply its input by its index in the registration loop. During testing, you notice every single callback behaves identically.
What Does This Print?
callbacks = []
for i in range(3):
callbacks.append(lambda x: x * i)
print([f(10) for f in callbacks])
The Output
All three generated lambda functions output 20 because they all reference the exact same variable i from the surrounding scope. By the time the lambdas are executed in the list comprehension, the loop has completed and the value of i remains stuck at its final value: 2.
Why Python Does This
Python's closures capture variables by reference, not by value. When the compiler encounters the lambda, it notes that i is a free variable belonging to the outer scope (following the LEGB rule). It compiles the bytecode to look up i dynamically in the enclosing namespace using LOAD_DEREF when the function is eventually called. Because the loop modifies i in-place within the same frame, all lambdas look up the exact same cell object, which contains the final value 2 by the time execution begins.
The Fix
callbacks = []
for i in range(3):
# Bind the current value of i as a default argument, which is evaluated at definition time
callbacks.append(lambda x, bound_i=i: x * bound_i)
print([f(10) for f in callbacks])
By passing the loop variable as a default argument to the lambda, its value is immediately bound to the function's scope at the time of definition (early binding). This creates a distinct copy of the variable's value for each lambda, preventing them from all referencing the same final variable.
How This Fails in Real Systems
A microservices health-check scheduler registered dynamic TCP polling tasks inside a loop. Due to late-binding closures, every single background task pinged only the very last host in the configuration file. The company remained unaware that 9 out of 10 microservices were down for over 4 hours because the dashboard reported the last healthy service repeatedly.