Mutable Default Argument Bugs
Default argument values are evaluated once at function definition time. Mutable defaults (list, dict, set) become shared state across calls, producing cross-request contamination and "sometimes wrong" test failures.
Recipe
Quick-reference recipe card - copy-paste ready.
# BUG
def append_item(item, bucket=[]):
bucket.append(item)
return bucket
# FIX
def append_item(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucketWhen to reach for this:
- API handlers accumulate items across unrelated requests
- Unit tests pass alone but fail when run as a suite
- Logging or metrics collectors grow forever across calls
@dataclassfields use mutable defaults incorrectly (related pattern)
Working Example
# broken_session_store.py
from dataclasses import dataclass, field
def broken_add_token(user_id: str, token: str, store: dict[str, list[str]] = {}) -> dict[str, list[str]]:
store.setdefault(user_id, []).append(token)
return store
# symptoms
broken_add_token("u1", "a")
broken_add_token("u2", "b")
print(broken_add_token("u1", "c")) # u1 now has tokens from earlier polluted store
# fixed_session_store.py
def add_token(
user_id: str,
token: str,
store: dict[str, list[str]] | None = None,
) -> dict[str, list[str]]:
if store is None:
store = {}
store.setdefault(user_id, []).append(token)
return store
@dataclass
class SessionStore:
_data: dict[str, list[str]] = field(default_factory=dict)
def add(self, user_id: str, token: str) -> None:
self._data.setdefault(user_id, []).append(token)
# tests/test_session_store.py
from fixed_session_store import SessionStore, add_token
def test_add_token_isolated_calls() -> None:
assert add_token("u1", "x") == {"u1": ["x"]}
assert add_token("u2", "y") == {"u2": ["y"]}
def test_session_store_dataclass() -> None:
a = SessionStore()
b = SessionStore()
a.add("u1", "t1")
assert b._data == {}What this demonstrates:
- Default
{}is one shared dict for all calls tobroken_add_token Nonesentinel creates a fresh dict per call when omitted@dataclassusesfield(default_factory=dict)for the same reason- Tests prove isolation between instances and calls
Deep Dive
How It Works
- Function defaults bind at
defexecution, not per call - The same list/dict object is reused when the caller omits the argument
- Dataclass field defaults follow the same rule without
default_factory - Immutable defaults (
None,0,"", tuples) are safe
Safe vs Unsafe Defaults
| Default | Safe? | Notes |
|---|---|---|
None | yes | use sentinel + fresh mutable inside |
() tuple | yes | immutable |
[], {}, set() | no | shared across calls |
datetime.now() | no | frozen at definition time |
Python Notes
# typing makes intent clear
def process(items: list[int] | None = None) -> list[int]:
working = list(items) if items is not None else []
working.append(1)
return workingGotchas
- Only some requests affected - heisenbug under concurrency reuses worker process memory. Fix: remove mutable defaults; restart masks but does not fix.
- Dataclass
= {}field - same bug in classes. Fix:field(default_factory=dict). - Copying the default manually wrong -
bucket = bucket or []treats empty list as missing. Fix:if bucket is None. - Mutating caller's list when provided - surprising side effect when default is not used. Fix:
working = list(bucket)if you must not alias input. - Caching with
@lru_cacheon methods with hidden mutable state - cache + mutation compounds confusion. Fix: pure functions or explicit instance state. - FastAPI
Dependswith mutable default - rare but possible in custom dependency signatures. Fix: useNonesentinel pattern.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
None sentinel | general functions | you need a distinguishable "empty" third state |
dataclasses.field(default_factory=...) | classes | plain functions |
| New class instance per request | FastAPI request-scoped state | simple pure helpers |
FAQs
Why does Python evaluate defaults at definition time?
Defaults are stored on the function object (__defaults__, __kwdefaults__) for performance and introspection. Mutable objects should not be used as those stored values.
Is `def f(x=time.time())` safe?
No. The timestamp is fixed at import/definition. Pass None and compute time.time() inside the function body.
How do I find these bugs in a large codebase?
Search for = [], = {}, = set() in def lines and dataclass fields without default_factory. ruff 0.9+ rules flag some patterns.
Does asyncio change this?
The same function object is shared across tasks on one event loop. Mutable defaults leak state between concurrent requests in one process.
What about default `list` in Pydantic models?
Pydantic 2 uses default_factory internally when you use Field(default_factory=list). Plain items: list = [] in raw dataclasses is still unsafe.
Can I use `bucket=[]` if I never mutate?
If you truly never mutate, a tuple default is clearer. Empty list defaults still confuse readers and linters.
Why not `bucket = bucket or []`?
An empty list [] is falsy; callers passing [] would get a new list unexpectedly. is None checks only the sentinel.
Are module-level globals better?
No. Globals create the same shared-state problems with worse testability. Prefer explicit parameters or instance attributes.
How do I debug a suspected shared default?
Print id(store) across calls without passing store. Identical IDs confirm the shared default object.
Do Flask `g` or FastAPI request state replace this pattern?
Request-scoped state belongs on g/request objects, not function defaults. Defaults are process-wide, not request-wide.
What about `functools.partial` with lists?
Partial binds arguments early; ensure bound mutable objects are not shared unintentionally across partials.
Is deepcopy on default a fix?
copy.deepcopy per call works but is slower and obscure. None sentinel is idiomatic and faster.
Related
- Domain Modeling - dataclass
default_factory - Memory Leak Scenarios - unbounded growth from shared caches
- Debugging Refactor Snippets - before/after fixes
- Debugging Tools - inspect with pdb
- Architecture Best Practices - avoid shared mutable state
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.