Why do all these functions return the same value?

A closure captures the variable, not the value it had when the function was defined. All three lambdas share one cell holding i, and they read it when called — by which time the loop has finished and left it at 2. Bind a value with a default argument, or make a new scope with a factory.

Overview

The question, and what it is testing

A closure captures the variable, not the value it had when the function was defined. All three lambdas share one cell holding i, and they read it when called — by which time the loop has finished and left it at 2. Bind a value with a default argument, or make a new scope with a factory.

Interviewers use this one because the answer separates people who have read the language from people who have only used it. Nothing here is obscure; all of it is observable, which is what the editor below is for.

Python semanticsConceptualMedium

Step through it

What to watch

  • 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.

Say this 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."

Why do all these functions return the same value?

What does [lambda: i for i in range(3)] give you when you call each one, and why?

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.

1Python
Output

The two fixes, and what each one actually does.

2Python
Output

It is not about lambda - and called inside the loop, late binding is what you wanted.

3Python
Output

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.

How the code works

The shared cell, both fixes, and the same bug with def instead of lambda so it is clear the syntax is not the cause.

How the code works

  1. late[0].__closure__[0] is late[1].__closure__[0]True. This is the whole explanation in one line: there is one box, and all three functions read it.
  2. lambda i=i: iThe right-hand i is evaluated now; the left-hand one is a parameter name. The value is stored on the function, so __closure__ is None — there is nothing to close over any more.
  3. [f.__closure__[0].cell_contents for f in factory][0, 1, 2]. Three calls to make made three frames, so there are three cells to read.
  4. [(lambda: i)() for i in range(3)][0, 1, 2], because each is called before the loop moves on. Late binding only hurts when the call is deferred, which is why the bug survives casual testing.

Change one thing

  • Rebind i = 99 after building late and call them again. They all return 99 — the cell is live, not a snapshot.
  • Replace the factory with functools.partial(lambda x: x, i) and check it gives [0, 1, 2] too. Same idea, different spelling.

Where this runs

Real CPython, compiled to WebAssembly and running on your own machine — nothing is uploaded. The first run takes a few seconds while the interpreter downloads; after that it is immediate. Need more room, or want to paste your own attempt? Use the Python compiler.

Check yourself

0 of 4

Answer without scrolling back up.

  1. What do the three lambdas in [lambda: i for i in range(3)] return?

  2. A Python closure captures:

  3. Why does lambda i=i: i fix it?

  4. Is this specific to lambda?

Cheat sheet

Why do all these functions return the same value?

A closure captures the variable, not the value it had when the function was defined. All three lambdas share one cell holding i, and they read it when called — by which time the loop has finished and left it at 2. Bind a value with a default argument, or make a new scope with a factory.

INTERVIEW · vizlearn.in/interview/closures-and-late-binding.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.