Mutable Default Arguments
The mutable-default trap is the most cited gotcha in Python: a parameter written
def f(x, acc=[])does not give each call a fresh empty list — it gives every call that omitsacca reference to one single list, created once when thedefstatement ran, and that one list silently accumulates state across calls. The result is a “fresh” container that quietly remembers everything previous calls put into it. The behavior is not a quirk of the list literal; it follows inevitably from the rule that a parameter default is a value computed once at function-definition time and stored on the function object — the full mechanism (defaults evaluated once, packed intofunction.__defaults__, copied by reference into empty slots on each call) is established in Default Arguments and Late Binding. This note owns the trap: how it presents in real code, how to recognize and diagnose it, the idiomatic=Nonesentinel fix and its variations, the rare cases where a mutable default is used on purpose, and how linters catch it before it ships.
The Trap in One Picture
The whole surprise reduces to a single fact: there is exactly one default object, and it lives on the function, not on the call. Calling the function with the argument omitted does not manufacture a new object — it copies a reference to the one already stored. If that object is mutable, mutating it is permanent and globally visible to every future call.
flowchart TD DEF["def f(x, acc=[]): runs ONCE<br/>creates one list, stores it on<br/>f.__defaults__ = ([],)"] DEF --> C1["call f(1) -- acc omitted"] C1 --> B1["acc bound to THE stored list<br/>append 1 -> [1]"] B1 --> C2["call f(2) -- acc omitted"] C2 --> B2["acc bound to THE SAME list<br/>append 2 -> [1, 2]"] B2 --> C3["call f(3) -- acc omitted"] C3 --> B3["acc bound to THE SAME list<br/>append 3 -> [1, 2, 3]"] B3 --> STATE["f.__defaults__ == ([1, 2, 3],)<br/>the trap is now visible<br/>in the function's frozen state"] style DEF fill:#e8f0ff style STATE fill:#ffe8e8
Diagram: the lifecycle of a mutable default. The blue node runs exactly once and creates the single shared list. Every subsequent call that omits acc (red path) binds to that same object and appends to it, so the “default empty list” is empty only on the very first call. The insight to take away: the accumulation is not hidden anywhere magical — it is sitting in f.__defaults__, observable directly, because the default is state attached to the function object rather than to the call.
Why It Happens (one paragraph — mechanism lives elsewhere)
When CPython executes a def statement, it evaluates each default expression right then, once, and bolts the resulting objects onto the new function as __defaults__ (a tuple, for positional/positional-or-keyword parameters) and __kwdefaults__ (a dict, for keyword-only parameters). On every later call that omits the argument, the interpreter’s initialize_locals routine copies the stored object into the frame’s local slot with PyStackRef_FromPyObjectNew(def) — a new reference to the same object, never a deep copy and never a re-evaluation of the original expression. The defining contrast is with the function body, whose names are resolved fresh on every call (late binding). A [] written as a default is on the def-time clock; a [] written inside the body is on the call-time clock. That single distinction — and the FromPyObjectNew reference-copy that implements it — is the entire root cause, and it is walked symbol-by-symbol with disassembly in Default Arguments and Late Binding. Everything below is downstream of it.
The decisive proof, on CPython 3.14.5, is that the accumulation is visible in the function’s own state:
def append_to(x, acc=[]):
acc.append(x)
return acc
append_to(1) # [1]
append_to(2) # [1, 2] -- not [2]!
append_to(3) # [1, 2, 3]
append_to.__defaults__ # ([1, 2, 3],) -- one list, holding everything
id(append_to.__defaults__[0]) # stable across all calls -- same objectThere is no second list anywhere. append_to.__defaults__[0] is the same object on every call (id() is constant), and it is precisely the list that the three appends grew.
The Canonical Bug and Its Symptoms
The textbook form is an accumulator helper:
def add_student(name, roster=[]):
roster.append(name)
return roster
freshman = add_student("Ada") # ['Ada'] -- looks right
sophomore = add_student("Bjarne") # ['Ada', 'Bjarne'] -- WRONG, leaked Ada
sophomore is freshman # True -- they are literally the same listThe symptom that sends people debugging in the wrong direction: the function “works” the first time and degrades from there. In a test suite that calls the function once, the bug is invisible; it surfaces only on the second invocation, which in production is often “the second request,” “the second row,” or “the second user.” Because the contamination crosses call boundaries, it frequently presents as spooky action at a distance — function A returns data that mysteriously contains entries that function B (or a previous request, or a previous test) produced. The two returned objects being is-identical (sophomore is freshman → True) is the smoking-gun diagnosis: a function that is supposed to return independent results is handing back aliases to one shared object.
The dict variant is identical in mechanism and just as common in configuration code:
def register(key, value, store={}):
store[key] = value
return store
register("a", 1) # {'a': 1}
register("b", 2) # {'a': 1, 'b': 2} -- 'a' should be goneA particularly nasty real-world incarnation is a default that is mutated indirectly. The parameter need not be appended to directly — passing it into anything that mutates it has the same effect:
def make_report(rows, header=["id", "name"]):
header.extend(compute_extra_columns(rows)) # mutates the shared default
return render(header, rows)Every report after the first carries the extra columns of every previous report. The mutation is buried one call deep, so a reader scanning make_report for an obvious .append sees nothing wrong.
Diagnosis checklist
When you suspect this bug, the fastest confirmations are:
- Inspect
f.__defaults__after a few calls. If the supposedly-empty default contains accumulated data, you have found it. This is the single most direct test and needs no instrumentation. - Check identity across calls. If two results that should be independent satisfy
a is b, they are aliases of one default. - Read the signature, not the body. Any default that is a list/dict/set literal, or any call returning a mutable object (
collections.defaultdict(),[*something],dict(...)), is a candidate. Immutable defaults (int,str,tuple,frozenset,None,bool) are always safe. - Bisect by passing the argument explicitly. If the misbehavior disappears the moment callers pass their own container, the default is the culprit.
The Fix: the None Sentinel Idiom
The standard, idiomatic, documented fix is to default to None — an immutable shared singleton that is harmless to share — and build the real mutable object inside the body, where it runs on the per-call clock:
def add_student(name, roster=None):
if roster is None:
roster = [] # fresh list on every call that omitted the arg
roster.append(name)
return rosterThe language reference recommends exactly this: “a way to share this value between subsequent calls” is the default’s default behavior, and to avoid it “you can … use None as the default value and explicitly test for it in the body” (compound statements). None works as a sentinel because it is a unique, immutable singleton: roster is None is an identity test that cannot be accidentally satisfied by a real argument value, and assigning a brand-new [] inside the body means the mutable object is born on the call-time clock and dies when the call ends (unless the caller keeps it).
Two precision points worth internalizing:
Use is None, not or. A tempting shortcut is roster = roster or []. It is wrong whenever an empty-but-legitimate container is passed: if a caller deliberately passes roster=[] (an empty list they own and want appended to), roster or [] evaluates the empty list as falsy and silently replaces it with a different fresh list, so the caller’s list never receives the append. is None tests for “argument omitted” precisely; or conflates “omitted” with “falsy” and breaks on every falsy-but-valid value (empty containers, 0, "", False). The mechanism note also uses is None for this reason — match it.
When a unique sentinel is needed instead of None. If None is itself a valid argument value the caller might legitimately pass (so you cannot tell “caller passed None” apart from “caller omitted the argument”), use a private module-level sentinel object:
_MISSING = object()
def configure(timeout=_MISSING):
if timeout is _MISSING:
timeout = default_timeout() # caller can still pass timeout=None meaningfully
...object() produces a unique identity that no caller can accidentally supply, so is _MISSING distinguishes “omitted” from every possible real value including None. This is the same pattern the standard library uses internally (for example, dict.pop’s “no default” behavior).
When an empty-tuple default is acceptable
A frequent and correct simplification is to default to an empty tuple when the parameter is only ever read, never mutated:
def render(items, classes=()):
return "".join(f"<li class='{' '.join(classes)}'>{i}</li>" for i in items)classes=() is safe specifically because a tuple is immutable — there is nothing to accumulate into, so sharing the one default tuple across all calls is harmless; the empty tuple () is even an interned singleton in CPython, so it costs nothing. This is the right default whenever the contract is “an optional sequence I will iterate over but not modify.” The moment the body needs to append to the parameter, the tuple default stops working (tuples have no .append), which is itself a useful guard rail: the type system pushes you toward the sentinel idiom exactly when mutation is required. Defaulting to () and treating it as read-only is strictly safer than defaulting to [], and many style guides prefer it for read-only optional-sequence parameters.
The Intentional Inverse: Memoization via Default
The same mechanism that causes the bug is occasionally used on purpose. Because a mutable default persists across calls, a default dict can serve as a per-function cache that survives between invocations — the “accidental” persistence is exactly what a cache wants:
def fib(n, _cache={0: 0, 1: 1}):
if n not in _cache:
_cache[n] = fib(n - 1) + fib(n - 2)
return _cache[n]Here the shared default _cache is the feature: every call sees and extends the same memo table, so repeated work is avoided. The leading underscore signals “private, do not pass this.” This is a real, if dated, idiom — but it should be considered a smell, not a recommendation, for several reasons: the cache is not introspectable or clearable by callers, it is invisible in the function’s documented signature, it confuses every reader who knows the mutable-default bug and now cannot tell intent from accident, and it cannot be disabled or sized. The idiomatic modern replacement is the decorator [[functools and lru_cache Internals|functools.lru_cache]] (or functools.cache, an unbounded alias), which provides the same memoization with a clean signature, a maxsize bound, a cache_clear() method, and cache_info() statistics (functools docs):
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n):
if n < 2:
return n
return fib(n - 1) + fib(n - 2)So: the mutable-default-as-cache trick demonstrates that the persistence is a mechanism, not a bug per se — but in production, reach for lru_cache. The one place the default-dict trick still earns its keep is micro-optimized hot paths where the decorator’s call overhead matters, or environments where the decorator is unavailable; even then, document it loudly.
A Subtler Variant: Mutable Default Built by a Call
The trap is not limited to literal []/{}. Any expression that produces a mutable object and is evaluated at def time is vulnerable, because the def-time clock evaluates it once:
import datetime
def log(event, when=datetime.datetime.now()): # BUG of a different flavor
...Here datetime.datetime.now() runs once, when the module is imported and def log executes — so every log entry is stamped with the import time, not the call time. This is the same root cause (default evaluated once at def time) wearing a different mask: the surprise is not shared mutation but a frozen-in-time value. The fix is again the sentinel: default to None and call datetime.datetime.now() inside the body. Ruff flags this specific shape as B008 (“function call in default argument”), separate from the mutable-container check, precisely because the “evaluated once” surprise applies to any call in a default, not just mutable literals (ruff B008).
B008 has deliberate exceptions
Some default-argument calls are intentionally def-time and benign — most famously FastAPI’s
Depends(...),Query(...), and Typer’s option constructors, which are meant to be evaluated once and inspected. Ruff’s B008 supports anextend-immutable-callsallowlist for exactly this reason. The rule is a heuristic for the “evaluated once” surprise, not a blanket ban; whitelisting framework markers is expected.
How Linters Catch It
Because this bug is so common and so mechanical, every major Python linter ships a dedicated check, and they are reliable enough that the trap rarely reaches code review in a linted codebase.
- Pylint
W0102—dangerous-default-value. Pylint flags any function whose default argument is a mutable literal or a call that yields a mutable object. The message symbol isdangerous-default-valueand the code isW0102(pylint W0102). It triggers ondef f(x=[]),def f(x={}),def f(x=set()), etc. - Ruff / flake8-bugbear
B006—mutable-argument-default. Ruff’sB006“checks for uses of mutable objects as function argument defaults” and shows the canonicalsome_list=[]accumulator as its example, explaining that “function defaults are evaluated once … so the same mutable object gets shared across all function calls” (ruff B006). Ruff can even autofix it by rewriting to theNone-sentinel form. - Ruff / flake8-bugbear
B008—function-call-in-default-argument. The companion check for thedatetime.now()flavor above: any function call in a default is “performed once, at definition time,” and the value reused thereafter (ruff B008).
In practice, enabling B006/B008 (the flake8-bugbear family in ruff) plus pylint’s default warning set catches essentially every instance at lint time. Treat a W0102/B006 warning as a true positive almost always: the rare intentional uses (the memoization trick, an immutable-by-convention default) are better written explicitly (lru_cache, an empty tuple, a sentinel) than suppressed with a # noqa.
Common Misunderstandings
- “A new
[]is created each call.” No — one list is created atdeftime and shared forever. The proof isf.__defaults__accumulating and the two results beingis-identical. - “It only affects lists.” It affects every mutable default: lists, dicts, sets,
bytearray, custom mutable objects, and the result of any call evaluated at def time. Immutable defaults (tuple,frozenset,str,int,None) are safe. - “
x = x or []fixes it.” It “fixes” the trap but introduces a new bug: it discards a legitimately-passed empty/falsy container. Useif x is None: x = []. - “Defaulting to
()is just a style choice.” It is a correctness choice when the parameter is read-only: an immutable default cannot accumulate. It stops compiling the instant you try to mutate it — which is the feature. - “The memoization-via-default trick is good Python.” It works, but it is a known smell;
functools.lru_cache/functools.cacheis the idiom. The trick survives only as a demonstration that the persistence is a mechanism, not strictly a bug.
See Also
- Default Arguments and Late Binding — the mechanism: why defaults are evaluated once at def time,
__defaults__/__kwdefaults__,SET_FUNCTION_ATTRIBUTE, and theFromPyObjectNewreference-copy at call time. - Reference vs Value Semantics in Python — why “binding the same object” (not copying it) is what makes the shared default mutate; the assignment-is-rebinding model underneath this whole trap.
- Late Binding Closures — the sibling §16 gotcha that also stems from the def-time/call-time distinction, from the other direction (body names resolved late).
- functools and lru_cache Internals — the idiomatic replacement for the memoization-via-default trick.
- CPython List Internals / CPython Dict Internals — what the shared mutable default actually is in memory.
- Python Internals MOC — §16, Common Gotchas.