You define a function inside a loop, collect the functions, call them later — and every single one returns the last value. The usual fix is a strange-looking line where the same name appears twice. Here is what is actually going on, and why the fix works.
This bug has a particular texture. The code reads correctly, each function looks like it captured its own value, and nothing raises. You only find out when the results are all identical — and if the loop happens to have one iteration during development, you never find out at all.
The explanation is one sentence long: a closure captures the variable, not the value. Everything else in this post is unpacking what that means and what to do about it.
Every snippet was run on CPython 3.10 before publishing, and the output shown is copied from those runs.
Contents
- The bug — three functions, one value
- Why it happens — variables, cells, and Python's missing block scope
- Anatomy of the fix — reading
def f(x: T = x)as three parts - Defaults are evaluated once — the rule that makes the fix work
- The same rule, as a trap — mutable default arguments
- Other ways to capture —
partial, factories, and which to prefer - Where it bites — threads, callbacks, handlers, tasks
- What the type hint does — and when it is evaluated
- Scoping rules, briefly — LEGB,
nonlocal, comprehensions - Letting a linter catch it — B023 and B008
- Cheat sheet
Part 1 · The bug
Three functions, one value
Build a list of functions in a loop. Each one returns the loop variable.
fns = []
for i in range(3):
def show():
return i
fns.append(show)
print([f() for f in fns])
[2, 2, 2]
Not [0, 1, 2]. All three functions return 2, the value i had when the loop
finished. The functions were created at three different times, but they were never looking at three
different values — they were all looking at the same i.
Nothing about the code is unusual, and the failure is silent. It also depends on when you
call: if you call show() inside the loop it returns the right thing, because i
still holds that value at that moment. Deferring the call is what exposes it.
Part 2 · Why it happens
Variables, cells, and the missing block scope
A closure is a function plus a reference to the enclosing scope's variables. Not copies of their values — references to the variables themselves. Python stores each captured variable in a cell, and every closure over that variable shares the one cell.
You can look at the cell directly:
def make():
n = 0
def inc():
nonlocal n
n += 1
return n
return inc
c = make()
print(c(), c(), c()) # 1 2 3
print(c.__closure__[0].cell_contents) # 3
The three calls returned different numbers because they all read and wrote the same cell. That is the whole point of a closure — and it is exactly what goes wrong in the loop, because the loop variable is also just a variable in the enclosing scope.
There is a second ingredient. In most C-like languages, for (int i = ...) creates a new
i scoped to the loop body. Python has no block scope: if, for and
while do not create scopes. Only modules, functions and classes do. So there is exactly
one i, it belongs to the enclosing function, and it outlives the loop:
for i in range(3):
pass
print(i) # 2 — still in scope, still holding the last value
Put the two facts together and the behaviour is inevitable:
- The loop rebinds one variable three times.
- All three closures reference that one variable.
- Calling them afterwards reads it once the loop has finished, so all three see the final value.
The name lookup happens when the function runs, not when it is defined. That is the general rule for every free variable in Python, and it is usually what you want — it is why you can define a function that calls a helper declared later in the file.
Part 3 · Anatomy of the fix
Reading def f(x: T = x) as three parts
The fix is to bind the value into the function's own signature. In a typed codebase it ends up looking like this, which is where the confusion starts:
def invoke(kernel: Kernel = kernel) -> None:
...
Three independent things are packed into kernel: Kernel = kernel:
def invoke(kernel: Kernel = kernel) -> None: ~~~~~~~~~~~~~~ ~~~~~~~~ │ │ │ │ │ └─ default value: the OUTER variable, │ │ evaluated now, at def time │ └─ type hint: the Kernel class └─ parameter name: a NEW local, created per call
The name appears twice because two different things happen to share a spelling. They live in different scopes and are resolved at different times:
Left kernel | Right kernel | |
|---|---|---|
| What it is | A parameter being declared | An expression being evaluated |
| Which scope | Local to invoke | The enclosing scope — the loop variable |
| When | On every call to invoke | Once, when the def statement runs |
| Effect | Shadows the outer name inside the body | Snapshots the current value |
That last row is the mechanism. Because the right-hand side is evaluated while the loop is on that iteration, the value is captured then and stored on the function object. And because the left-hand side introduces a local with the same name, the body reads the captured parameter instead of the outer variable — the closure is gone.
fns = []
for i in range(3):
def show(i=i): # left i = new parameter; right i = this iteration's value
return i
fns.append(show)
print([f() for f in fns])
[0, 1, 2]
You can see the snapshot on the function object. Rebinding the outer name afterwards changes nothing:
x = 10
def f(a=x):
return a
x = 999
print(f.__defaults__, f(), x)
(10,) 10 999
show(i=i) is an honest fix but not a free one: show now accepts an argument, and a
caller can override the captured value. For an internal callback that is harmless. For a function
you hand to someone else, prefer a factory (below) so the capture is not part of the API.
Part 4 · Defaults are evaluated once
The rule underneath
Default argument expressions are evaluated once, when the def statement executes —
not on each call. This single rule explains both the fix above and the trap below.
Prove it with a default that announces itself:
def stamp(value=print(" (default evaluated)")):
return value
print(" function defined, not called yet")
stamp()
stamp()
(default evaluated)
function defined, not called yet
The message appears before "function defined", and appears only once despite two calls. The default was computed while Python was building the function object, and the result was stored on it.
So def show(i=i) is not a clever trick, it is the ordinary rule applied deliberately: you
are asking for an expression to be evaluated now and remembered.
Part 5 · The same rule, as a trap
Mutable default arguments
Evaluated once means the default object is created once and reused by every call. If it is mutable, every call shares it — and the sharing persists for the lifetime of the function.
def append_bad(item, bucket=[]):
bucket.append(item)
return bucket
print(append_bad("a"))
print(append_bad("b"))
print(append_bad.__defaults__)
['a']
['a', 'b']
(['a', 'b'],)
The second call did not start from an empty list. The [] in the signature is one list object,
created once, now permanently attached to the function — you can see it grow inside
__defaults__.
The fix is the None sentinel. Take the default as None and build the real thing inside:
def f(bucket=[]):
bucket.append(1)
return bucket
def g(cache={}):
...
def h(when=datetime.now()):
... # frozen at import time!
def f(bucket=None):
if bucket is None:
bucket = []
bucket.append(1)
return bucket
def g(cache=None):
cache = {} if cache is None else cache
def h(when=None):
when = when or datetime.now()
def h(when=datetime.now()) looks like "default to now". It means "default to the moment this
module was imported". A long-running process will hand out the same timestamp for days, and the
test suite will pass because it imports and calls within the same second.
A mutable default is occasionally deliberate — a memo dictionary that must persist across calls.
It works, but it is invisible to the reader. Prefer an explicit module-level variable or
functools.cache, both of which say what they mean.
Part 6 · Other ways to capture
partial, factories, and which to prefer
A factory function
Give the value a scope of its own by passing it as a parameter to an outer function. The inner closure then captures that parameter, and each call to the factory creates a fresh one.
def make_show(value):
def show():
return value # captures the parameter, not the loop variable
return show
fns = [make_show(i) for i in range(3)]
print([f() for f in fns]) # [0, 1, 2]
This is the version to reach for in library code: the signature of show stays clean, and the
capture is explicit rather than a signature side effect.
functools.partial
from functools import partial
def show(value):
return value
fns = [partial(show, i) for i in range(3)]
print([f() for f in fns]) # [0, 1, 2]
partial binds arguments immediately and stores them on the resulting object, so there is no
closure to go stale. It is the cleanest option when the function already exists and takes the value
as a parameter.
Comparison
| Technique | Good | Cost | Use when |
|---|---|---|---|
def f(x=x) |
One token; no restructuring | Adds a parameter callers can override | Local callbacks, quick lambdas |
| Factory function | Signature stays clean; intent explicit | An extra function | Library code, anything exported |
partial |
No new scope; composes well | Function must accept the value | The callable already exists |
| Bind to an instance attribute | Natural when state is already an object | A class | Several values travel together |
Part 7 · Where it bites
Anything that defers the call
The pattern is always the same: a callable is created in a loop and invoked later. Threads make it vivid because the fix and the bug differ by six characters.
import threading
results = []
threads = [
threading.Thread(target=lambda: results.append(name))
for name in ("a", "b", "c")
]
for t in threads: t.start()
for t in threads: t.join()
print("buggy:", sorted(results))
results = []
threads = [
threading.Thread(target=lambda name=name: results.append(name))
for name in ("a", "b", "c")
]
for t in threads: t.start()
for t in threads: t.join()
print("fixed:", sorted(results))
buggy: ['c', 'c', 'c']
fixed: ['a', 'b', 'c']
The same shape shows up in:
- Event handlers. Buttons built in a loop that all act on the last item.
- Async tasks.
asyncio.create_taskover a coroutine closing on a loop variable. - Handler dictionaries.
{name: lambda: dispatch(name) for name in names}— the dict comprehension has its own scope, but the lambdas still close over its variable. - Retry and timing wrappers. A benchmark harness that builds one thunk per case and runs them afterwards ends up measuring the last case repeatedly.
- Deferred logging.
log.debug(lambda: f"{item}")style lazy messages.
When the deferred callables are benchmarks, nothing crashes and every number looks plausible — you simply publish the same case's timing under several names. There is no error to notice, only a conclusion that is wrong.
Part 8 · What the type hint does
The middle part, and when it runs
In kernel: Kernel = kernel, the annotation is the only part with no effect on behaviour.
Python does not check it, coerce with it, or consult it when binding arguments. It is metadata for
readers and for tools such as mypy or pyright.
But it is an expression, and by default it is evaluated at definition time:
def side_effect():
print(" (annotation evaluated)")
return int
def g(a: side_effect() = 1):
return a
print(g.__annotations__)
(annotation evaluated)
{'a': <class 'int'>}
Add from __future__ import annotations and they are kept as strings instead, never evaluated
unless something asks for them:
{'a': 'side_effect()'} # note: no "(annotation evaluated)" line
(1,) # __defaults__ — still evaluated eagerly
Two things worth carrying away. First, deferring annotations lets you reference a class before it is defined, which is why the future import is common in typed code. Second, it changes nothing about defaults — those are always evaluated immediately, which is exactly what the capture idiom relies on.
Writing kernel: Kernel = kernel rather than kernel=kernel documents the parameter
and gives a type checker something to verify. The value that gets captured, and when, is identical
either way.
Part 9 · Scoping rules, briefly
LEGB, nonlocal, and comprehensions
Python resolves a name by searching four scopes in order:
| Scope | Is |
|---|---|
| Local | Names assigned in the current function |
| Enclosing | Locals of any lexically enclosing function — this is where closures read from |
| Global | Module level |
| Built-in | len, print, and friends |
Assigning to a name makes it local for the whole function, which is why reading it before the assignment raises rather than falling through to an outer scope. To assign to an outer name instead, declare the intent:
count = 0
def outer():
total = 0
def bump():
nonlocal total # assign to outer()'s local
global count # assign to the module-level name
total += 1
count += 1
bump(); bump()
return total
print(outer(), count) # 2 2
Comprehensions are the one construct that looks like a loop but is not: each has its own scope, so its variable does not leak. That is a small blessing and a common source of surprise:
squares = [j * j for j in range(3)]
print(squares) # [0, 1, 4]
print(j) # NameError: name 'j' is not defined
The comprehension's variable is scoped, yet a lambda created inside still closes over it and still
reads it late. [lambda: j for j in range(3)] gives three functions that all return
2. Scoping the variable is not the same as snapshotting it.
Part 10 · Letting a linter catch it
B023 and B008
You should not have to spot this by eye. flake8-bugbear and Ruff both ship the checks; enable
them once and the class of bug stops reaching review.
| Rule | Catches |
|---|---|
B023function-uses-loop-variable |
A function defined in a loop that references the loop variable — the bug in part 1 |
B006mutable-argument-default |
def f(x=[]) and friends — the trap in part 5 |
B008function-call-in-default-argument |
def f(when=datetime.now()) — a call evaluated once at import |
[tool.ruff.lint]
extend-select = ["B"] # all of flake8-bugbear, including B006/B008/B023
$ ruff check --select B023,B006,B008 --output-format concise .
b008.py:3:12: B008 Do not perform function call `datetime.now` in argument defaults;
instead, perform the call within the function, or read the default from
a module-level singleton variable
late.py:4:16: B023 Function definition does not bind loop variable `i`
mutable.py:1:29: B006 Do not use mutable data structures for argument defaults
Found 3 errors.
Some frameworks make a call in the default meaningful — FastAPI's Depends() is the usual
example. Ruff has extend-immutable-calls for exactly this; add the specific callable rather
than switching the rule off.
Wrapping up
Cheat sheet
| Expression | Evaluated | Consequence |
|---|---|---|
| Function body | On every call | Free variables are read late |
| Default argument | Once, at def | Snapshots values; shares mutables |
| Annotation | Once, at def — or never, with the future import | No runtime effect either way |
| Decorator | Once, at def | Wrapping happens at definition time |
The short list
- A closure captures the variable, not the value. If a function outlives the loop that made it, it will see the loop's final state.
-
def f(x=x)works because defaults are evaluated once, at definition. Left is a new local, right is the outer variable read right now. Same word, different things. -
That same rule is the mutable-default trap. One
[], created once, shared by every call. UseNoneand build inside. -
Turn on bugbear (
B) in Ruff. B023, B006 and B008 cover all three failure modes above, and none of them announce themselves at runtime.
Further reading
- Python reference — function definitions: the paragraph stating defaults are evaluated once, left to right
- Official FAQ: the loop-lambda question, answered in four lines
- PEP 563 and PEP 649: how and when annotations get evaluated
- Ruff — flake8-bugbear rules: B006, B008, B023 and the rest
No comments:
Post a Comment