def append_to(item, target=[]) looks like it starts from an empty list every time. It does
not. One list is created when the def statement runs, and every call for the rest of the
program's life shares it.
This is the most famous gotcha in Python, and it is famous for a reason: the code is short, the intent is obvious, and it is wrong. There is no error, no warning at runtime, and the symptom depends on what earlier callers happened to do — which makes it genuinely hard to track down when you meet it in a real codebase rather than in a blog post.
It is also a two-minute fix once you know the rule. Every output below is copied from a real run on CPython 3.10.
Contents
- The bug — three calls, one list
- Why it happens — the default lives on the function object
- The shape you actually hit — two carts, one basket
- The fix — the
Nonesentinel - Variations and near-misses —
or [], sentinel objects, dataclasses - What is safe, what is not
- Letting a linter catch it — B006 vs B008
- Cheat sheet
Part 1 · The bug
Three calls, one list
def append_to(item, target=[]):
target.append(item)
return target
print(append_to(1))
print(append_to(2))
print(append_to(3))
[1]
[1, 2] ← expected [2]
[1, 2, 3] ← expected [3]
The first call behaves. The second reveals that target was not empty when the call started.
The list is accumulating across calls, because it is the same list each time.
Part 2 · Why it happens
The default lives on the function object
A default argument expression is evaluated once, when the def statement executes —
not on each call. The resulting object is stored on the function and handed to every call that omits
the argument. So [] is not an instruction to make a fresh list; it is one list, made once.
def append_to(item, target=[]): ~~ evaluated once, at def time — the resulting list is stored on the function call 1 ──┐ call 2 ──┼──▶ that one list ──▶ [1] ─▶ [1, 2] ─▶ [1, 2, 3] call 3 ──┘ (never replaced, never reset)
You do not have to take this on faith. The list is reachable through __defaults__:
def append_to(item, target=[]):
target.append(item)
return target
print(append_to.__defaults__)
append_to(1)
print(append_to.__defaults__)
append_to(2)
print(append_to.__defaults__)
([],)
([1],) ← the list attached to the function has changed
([1, 2],)
That is the whole story. The default is a piece of state hanging off the function, and
target.append(item) mutates it. Nothing resets it between calls, because nothing was ever
going to.
Rebinding is fine. def f(n=0): n += 1 cannot leak, because n += 1 on an integer
creates a new object and points the local name at it — the stored default is untouched. The bug
needs mutation of the default object, which is why only mutable types are affected.
Part 3 · The shape you actually hit
Two carts, one basket
Nobody writes append_to in production. What people do write is a constructor with an empty
collection as its default — and then the shared object is shared between instances:
class Cart:
def __init__(self, items=[]):
self.items = items
a = Cart()
b = Cart()
a.items.append("apple")
print(b.items)
print(a.items is b.items)
print(Cart.__init__.__defaults__)
['apple'] ← a different cart, with someone else's apple in it
True ← it was the same list all along
(['apple'],) ← and it is living on Cart.__init__
Two independent objects, one shared basket. Worse, the default is attached to the class, so the
contamination outlives both instances and reaches every Cart() created later in the process.
The reproduction condition is "what did some earlier caller put in there", which is not visible
from the failing line, is not in the traceback, and often depends on test ordering. A unit test
that constructs one Cart passes. The suite fails only when another test ran first — and
then passes again when you run it alone to investigate.
Passing the argument explicitly sidesteps it, which is part of why the bug hides so well: the code path that everyone tests is usually the one that supplies a value.
c = Cart(items=[]) # a genuinely new list
c.items.append("pear")
print(c.items, a.items) # ['pear'] ['apple']
Part 4 · The fix
The None sentinel
Make the default an immutable marker, and build the real object inside the body. The construction then happens per call, which is what you wanted in the first place.
def append_to(item, target=[]):
target.append(item)
return target
class Cart:
def __init__(self, items=[]):
self.items = items
def append_to(item, target=None):
if target is None:
target = []
target.append(item)
return target
class Cart:
def __init__(self, items=None):
self.items = [] if items is None else items
append_to(1) → [1]
append_to(2) → [2]
append_to(3) → [3]
append_to.__defaults__ → (None,) ← nothing to accumulate into
None is a singleton and immutable, so storing it on the function is harmless. The important
part is the is None check: it is what turns "one object, made once" into "a new object per
call".
Type it honestly
If the codebase is annotated, the parameter now genuinely accepts None, and the annotation
should say so:
def append_to(item: int, target: list[int] | None = None) -> list[int]:
if target is None:
target = []
target.append(item)
return target
On Python 3.9 and earlier, write Optional[List[int]] from typing, or add
from __future__ import annotations to use the | syntax anyway.
Part 5 · Variations and near-misses
Three things worth knowing
or [] is shorter and subtly different
target = target or [] is a common abbreviation. It fixes the sharing, but it also replaces any
falsy argument the caller passed — including an empty list they expected you to fill:
def with_or(item, target=None):
target = target or [] # empty list from the caller gets discarded
target.append(item)
return target
def with_is_none(item, target=None):
target = [] if target is None else target
target.append(item)
return target
mine = []
with_or("x", mine)
print("or →", mine)
mine = []
with_is_none("x", mine)
print("is None →", mine)
or → [] ← the caller's list was never touched
is None → ['x']
The or version quietly appended to a throwaway list and the caller's stayed empty. Same class
of confusion for 0, "" and False. Prefer is None: it tests for
exactly the thing you meant.
When None is itself a valid value
Sometimes None is meaningful data and you still need to distinguish "not supplied". Use a
private sentinel object:
_MISSING = object()
def fetch(key, default=_MISSING):
if default is _MISSING:
raise KeyError(key) # no default supplied
return default # default supplied — possibly None
print(fetch("k", None)) # None
fetch("k") # KeyError: 'k'
This is how dict.pop and friends behave, and why they can tell those two cases apart.
Dataclasses refuse to let you do it
@dataclass knows about this bug and raises at class-creation time rather than letting you ship
it:
from dataclasses import dataclass
@dataclass
class Bad:
items: list = []
ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory
Note that this fires at import, before any instance exists — the decorator inspects the defaults while building the class. The sanctioned form is a factory, called once per instance:
from dataclasses import dataclass, field
@dataclass
class Good:
items: list = field(default_factory=list)
g1, g2 = Good(), Good()
g1.items.append("apple")
print(g1.items, g2.items, g1.items is g2.items)
['apple'] [] False
default_factory is the "call it per invocation" idea made explicit — the same thing the
None check does by hand.
A mutable default occasionally is the point — a memo dict that must persist across calls. It
works, but it hides a piece of global state in a signature where no reader expects it. Use a
module-level variable or functools.cache instead; both announce themselves.
Part 6 · What is safe, what is not
A quick classification
The rule follows directly from the mechanism: the stored object is only a hazard if something can mutate it.
| Default | Safe? | Because |
|---|---|---|
None, 0, "", True | yes | Immutable; cannot be changed in place |
(), frozenset() | yes | Immutable containers |
| A module-level constant | usually | Safe if genuinely immutable — a shared CONFIG dict is not |
[], {}, set() | no | One object, mutated in place by every caller |
| A custom class instance | no | One instance shared across calls |
datetime.now() | no | Not mutation — it freezes at import time |
That last row is a different bug wearing the same clothes, and it is worth seeing on its own:
import time
from datetime import datetime
def stamped(when=datetime.now()):
return when
t1 = stamped()
time.sleep(0.05)
t2 = stamped()
print(t1 == t2) # True — both are the moment the module was imported
A long-running service will hand out the same timestamp for days. The test suite will not notice, because it imports and calls within the same instant.
Part 7 · Letting a linter catch it
B006 and B008
You should never be finding this by reading. flake8-bugbear — bundled into Ruff as the
B rules — catches both variants:
| Rule | Catches | Example |
|---|---|---|
B006mutable-argument-default |
A mutable literal or constructor as a default — the bug in this post | def f(x=[]) |
B008function-call-in-default-argument |
Any function call in a default, evaluated once at import | def f(t=datetime.now()) |
[tool.ruff.lint]
extend-select = ["B"] # all of flake8-bugbear, B006 and B008 included
app.py:4:28: B006 Do not use mutable data structures for argument defaults
app.py:9:21: B006 Do not use mutable data structures for argument defaults
app.py:13:18: 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
app.py:18:30: B006 Do not use mutable data structures for argument defaults
Found 4 errors.
target=[] is B006, not B008. The advice "perform the call within the function"
belongs to B008's message — apt for datetime.now(), but a list literal is not a call. The
remedy happens to be the same shape in both cases: move the work inside the body.
Some frameworks give a call in the default an actual meaning — FastAPI's Depends() is the
standard example. Ruff's extend-immutable-calls exists for these; list the specific callable
rather than disabling the rule.
Wrapping up
Cheat sheet
| Instead of | Write |
|---|---|
def f(x=[]) | def f(x=None) then if x is None: x = [] |
def f(x={}) | def f(x=None) then x = {} if x is None else x |
def f(t=datetime.now()) | def f(t=None) then t = t or datetime.now() |
x = x or [] | x = [] if x is None else x — unless discarding falsy input is intended |
items: list = [] in a dataclass | items: list = field(default_factory=list) |
| A memo dict in the signature | A module-level variable, or functools.cache |
The short list
-
Defaults are evaluated once, at
deftime. The object is stored on the function and reused forever;[]means one list, not a fresh one per call. -
You can watch it happen in
f.__defaults__— the list grows in place. -
The damage scales in constructors. A default
items=[]in__init__is shared by every instance the process ever creates. -
Default to
Noneand build inside the body. Test withis None, notor, so a caller's empty list is still theirs. -
Enable
Bin Ruff. B006 and B008 find every instance in seconds, and neither bug ever announces itself at runtime.
Further reading
- Python reference — function definitions: the paragraph that states defaults are evaluated once, and warns about this exact case
- Official FAQ — why are default values shared between objects?
- dataclasses — mutable default values: why
default_factoryexists - Ruff — B006 and B008
No comments:
Post a Comment