7/29/2026

The list that remembers: mutable default arguments in Python

Python · Language

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

  1. The bug — three calls, one list
  2. Why it happens — the default lives on the function object
  3. The shape you actually hit — two carts, one basket
  4. The fix — the None sentinel
  5. Variations and near-misses or [], sentinel objects, dataclasses
  6. What is safe, what is not
  7. Letting a linter catch it — B006 vs B008
  8. Cheat sheet

Part 1 · The bug

Three calls, one list

append.py
def append_to(item, target=[]):
    target.append(item)
    return target

print(append_to(1))
print(append_to(2))
print(append_to(3))
$ python append.py
[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__:

show_default.py
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__)
$ python show_default.py
([],)
([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.

Note what is not the problem

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:

cart.py
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__)
$ python cart.py
['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.

Why it is hard to debug

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.

explicit argument, no sharing
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.

broken
def append_to(item, target=[]):
    target.append(item)
    return target


class Cart:
    def __init__(self, items=[]):
        self.items = items
correct
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
the fixed version
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:

annotated
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:

or_trap.py
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)
$ python or_trap.py
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:

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:

bad_dc.py
from dataclasses import dataclass

@dataclass
class Bad:
    items: list = []
$ python bad_dc.py
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:

good_dc.py
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)
$ python good_dc.py
['apple'] [] False

default_factory is the "call it per invocation" idea made explicit — the same thing the None check does by hand.

When the sharing is deliberate

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.

DefaultSafe?Because
None, 0, "", TrueyesImmutable; cannot be changed in place
(), frozenset()yesImmutable containers
A module-level constantusuallySafe if genuinely immutable — a shared CONFIG dict is not
[], {}, set()noOne object, mutated in place by every caller
A custom class instancenoOne instance shared across calls
datetime.now()noNot 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:

frozen at import, not "now"
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:

RuleCatchesExample
B006
mutable-argument-default
A mutable literal or constructor as a default — the bug in this post def f(x=[])
B008
function-call-in-default-argument
Any function call in a default, evaluated once at import def f(t=datetime.now())
pyproject.toml
[tool.ruff.lint]
extend-select = ["B"]      # all of flake8-bugbear, B006 and B008 included
$ ruff check --select B006,B008 --output-format concise .
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.
The two rules get conflated

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.

B008 has real exceptions

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 ofWrite
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 dataclassitems: list = field(default_factory=list)
A memo dict in the signatureA module-level variable, or functools.cache

The short list

  • Defaults are evaluated once, at def time. 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 None and build inside the body. Test with is None, not or, so a caller's empty list is still theirs.
  • Enable B in Ruff. B006 and B008 find every instance in seconds, and neither bug ever announces itself at runtime.

Further reading

Every runnable snippet verified against CPython 3.10.

No comments:

Post a Comment