7/28/2026

The loop variable that changed: late binding and default arguments in Python

Python · Language

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

  1. The bug — three functions, one value
  2. Why it happens — variables, cells, and Python's missing block scope
  3. Anatomy of the fix — reading def f(x: T = x) as three parts
  4. Defaults are evaluated once — the rule that makes the fix work
  5. The same rule, as a trap — mutable default arguments
  6. Other ways to capture partial, factories, and which to prefer
  7. Where it bites — threads, callbacks, handlers, tasks
  8. What the type hint does — and when it is evaluated
  9. Scoping rules, briefly — LEGB, nonlocal, comprehensions
  10. Letting a linter catch it — B023 and B008
  11. Cheat sheet

Part 1 · The bug

Three functions, one value

Build a list of functions in a loop. Each one returns the loop variable.

late.py
fns = []
for i in range(3):
    def show():
        return i
    fns.append(show)

print([f() for f in fns])
$ python late.py
[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.

Why it survives review

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:

the cell is shared and mutable
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:

the loop variable leaks
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.
"Late binding" names the timing

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:

the idiom
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 kernelRight kernel
What it isA parameter being declaredAn expression being evaluated
Which scopeLocal to invokeThe enclosing scope — the loop variable
WhenOn every call to invokeOnce, when the def statement runs
EffectShadows the outer name inside the bodySnapshots 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.

fixed.py
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])
$ python fixed.py
[0, 1, 2]

You can see the snapshot on the function object. Rebinding the outer name afterwards changes nothing:

the value is stored, not looked up
x = 10
def f(a=x):
    return a

x = 999
print(f.__defaults__, f(), x)
output
(10,) 10 999
It changes the public signature

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:

once.py
def stamp(value=print("  (default evaluated)")):
    return value

print("  function defined, not called yet")
stamp()
stamp()
$ python once.py
  (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.

mutable.py
def append_bad(item, bucket=[]):
    bucket.append(item)
    return bucket

print(append_bad("a"))
print(append_bad("b"))
print(append_bad.__defaults__)
$ python mutable.py
['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:

avoid
def f(bucket=[]):
    bucket.append(1)
    return bucket

def g(cache={}):
    ...

def h(when=datetime.now()):
    ...       # frozen at import time!
prefer
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()
The subtlest version of this bug

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.

When sharing is the point

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.

factory
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

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

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

threads.py
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))
$ python threads.py
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_task over 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.
The measurement version is the nastiest

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:

annotations are evaluated eagerly
def side_effect():
    print("  (annotation evaluated)")
    return int

def g(a: side_effect() = 1):
    return a

print(g.__annotations__)
output
  (annotation evaluated)
{'a': <class 'int'>}

Add from __future__ import annotations and they are kept as strings instead, never evaluated unless something asks for them:

output with the future import
{'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.

Hints do not narrow the capture

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:

ScopeIs
LocalNames assigned in the current function
EnclosingLocals of any lexically enclosing function — this is where closures read from
GlobalModule level
Built-inlen, 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:

nonlocal vs global
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:

comprehension scope
squares = [j * j for j in range(3)]
print(squares)             # [0, 1, 4]
print(j)                   # NameError: name 'j' is not defined
But it does not save you

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.

RuleCatches
B023
function-uses-loop-variable
A function defined in a loop that references the loop variable — the bug in part 1
B006
mutable-argument-default
def f(x=[]) and friends — the trap in part 5
B008
function-call-in-default-argument
def f(when=datetime.now()) — a call evaluated once at import
pyproject.toml
[tool.ruff.lint]
extend-select = ["B"]      # all of flake8-bugbear, including B006/B008/B023
what it looks like
$ 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.
B008 has legitimate exceptions

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

ExpressionEvaluatedConsequence
Function bodyOn every callFree variables are read late
Default argumentOnce, at defSnapshots values; shares mutables
AnnotationOnce, at def — or never, with the future importNo runtime effect either way
DecoratorOnce, at defWrapping 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. Use None and 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

Every runnable snippet verified against CPython 3.10.

No comments:

Post a Comment