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.
What to say out loud
Python closures are late-binding: they look the variable up at call time, not definition time. So every lambda in that loop sees the final value. I fix it with a default argument, lambda i=i: i, which evaluates i when the lambda is created, or with a factory function so each closure gets its own scope.
Then stop. The commonest failure on a question like this is answering it correctly in one sentence and then talking for another minute until something wrong comes out.
What to notice while it runs
- The three functions share one cell — that is why they agree.
lambda i=i: i captures a value because defaults are evaluated at definition time.
- The factory version has three separate cells, each with its own value.
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.
The follow-ups interviewers ask
"How do you share mutable state between closures on purpose?" That is what late binding gives you for free: several closures over the same cell all see each other's writes. To rebind rather than mutate, the inner function needs nonlocal; without it an assignment creates a new local and the outer variable never changes.
"What is the difference between nonlocal and global?" nonlocal rebinds in the nearest enclosing function scope; global rebinds at module level. Reaching for global to fix a closure is a common wrong turn - it works in a script and breaks the moment the code is called twice or from two threads.
"Does this happen with asyncio too?" Yes, and it is worse there because the call is deferred by construction. for url in urls: tasks.append(asyncio.create_task(fetch(url))) is fine because fetch(url) is called immediately - but a lambda or a closure referring to url inside the task body sees the last one.
Common wrong answers
"Declare i global." Wrong scope, and it makes the functions depend on module state. It also does not fix anything: a global is still one name looked up at call time.
"It is a lambda problem." A nested def in the same loop behaves identically. Saying "lambda" suggests the cause is the syntax rather than the scope.
"Copy i inside the lambda." There is nothing to copy at definition time, because the body does not run then. The default-argument form works precisely because defaults are evaluated then.
Recap in one screen
- The three functions share one cell - that is why they agree.
- lambda i=i: i captures a value because defaults are evaluated at definition time.
- The factory version has three separate cells, each with its own value.
- The one-line answer: A closure captures the variable, not the value it had when the function was defined.
- Worth trying: Rebind i = 99 after building late and call them again. They all return 99 - the cell is live, not a snapshot.
- Worth trying: Replace the factory with functools.partial(lambda x: x, i) and check it gives [0, 1, 2] too. Same idea, different spelling.