Run it
The shared cell, both fixes, and the same bug with def instead of lambda so it is clear the syntax is not the cause.
The shared cell, read straight off the functions.
The two fixes, and what each one actually does.
It is not about lambda - and called inside the loop, late binding is what you wanted.
Capturing a name, not a value
When a nested function refers to a variable from an enclosing scope, Python does not copy the value in. It stores a reference to a cell — a small box holding that variable — and reads the cell when the function runs. You can see them: f.__closure__ is a tuple of cells, and .cell_contents is what each holds right now.
A loop body is not a new scope in Python, so every iteration refers to the same i and therefore the same cell. Three lambdas, one cell, one answer. This is not a quirk of lambda: a nested def in the same loop behaves identically.
The two fixes, and what they really do
A default argument. lambda i=i: i works because default expressions are evaluated when the function object is created, so the current value is captured and stored on the function. It is the shortest fix and it changes the signature, which means a caller can override it — usually harmless, occasionally surprising.
A factory. def make(i): return lambda: i gives each closure a genuinely separate scope, because each call to make creates its own frame and its own cell. It is more code and it is the version that scales to closing over several variables. functools.partial(op, i) is the same idea from the standard library.
Why late binding is the right default
It is easy to read this as a design mistake. It is the behaviour that makes ordinary closures work: a function that closes over self, a counter, or a configuration dictionary is supposed to see the current value, not a snapshot from whenever it was defined. Mutual recursion between two nested functions relies on it, and so does any callback that reads state updated after it was registered.
The loop case is the one place where you wanted a snapshot and the language gave you a reference. Every other closure in your program is benefiting from the same rule.
Where it bites in real code
Callbacks registered in a loop — buttons, handlers, retry functions — all firing with the last item. Tasks created in a loop and appended to a list. And functools.reduce-style pipelines built by stacking lambdas over a loop variable.
The tell is always the same: several functions built in a loop, called after it. If they are called inside the loop, late binding reads the value you expected and the bug never appears — which is why this survives testing so well.
Edge cases to raise
Volunteering these is most of what separates a correct answer from a good one.
The cell is live, not a snapshot. Rebinding the variable after the functions are built changes what they all return - which is occasionally the feature and usually the bug.
The default-argument fix changes the signature. A caller can pass their own value and override the captured one. Harmless in a loop of throwaway callbacks, worth avoiding in a public API.
Comprehensions have their own scope, but the loop variable is still shared by closures built inside them - the scope stops the name leaking out, it does not give each iteration its own cell.