Python
Mutable default argument retaining state across calls
Written and reviewed by Sahil Srivastav
def add_item(item, bucket=[]):
bucket.append(item)
return bucket
>>> add_item(1)
[1]
>>> add_item(2)
[1, 2]What this error actually means
Python evaluates a function’s default expressions once, when the `def` statement executes, and stores the resulting object on the function. Every call that omits the argument receives the same list or dictionary.
This is not a copy-on-call bug. It becomes dangerous when a helper is used by multiple requests, tenants, or threads: data from one invocation becomes input to the next. A test that calls the function once cannot expose it, and a process restart appears to “fix” production temporarily.
The same mechanism can be intentional for a cache, but intentional shared state should be named, bounded, and synchronised rather than hidden in a signature.
Causes, most common first
- 1List, dict, set, or custom object as a default. The object is created once and reused whenever the caller omits the parameter.
- 2Default used as an implicit cache. A convenience pattern becomes unbounded process state.
- 3Mutable state shared across threads. Even after recognising the shared object, concurrent mutation can race.
When you see it
- A list grows across independent calls
- Tests pass alone but fail when run in one process
- One user sees another user’s accumulated values
- State resets after worker restart
- The default object appears in `func.__defaults__`
How to diagnose it
Step 1
Inspect the default object identity
The object remains the same across calls.
python - <<'PY'
def f(x, xs=[]): xs.append(x); return xs
print(f.__defaults__, id(f.__defaults__[0])); f(1); print(id(f.__defaults__[0]), f(2))
PYStep 2
Run order-dependent tests
Call the helper twice with independent expectations in the same interpreter and repeat under parallel tests.
pytest -q --randomly-seed=lastStep 3
Search signatures
Review mutable literals and constructors in defaults.
rg -n 'def .*=[\[\]{}]|def .*=(dict|list|set)\(' src testsThe fix
Use `None` as the sentinel and allocate inside the function.
If callers may deliberately pass a mutable object, document that the function mutates it.
For a real cache, use an explicit cache object with eviction and locking, or `functools.lru_cache` for hashable immutable inputs.
Clear process state at lifecycle boundaries only when shared state is intentional; do not use restart as a data-isolation fix.
Add concurrency tests when the helper serves requests.
def add_item(item, bucket=None):
if bucket is None:
bucket = []
bucket.append(item)
return bucketHow to stop it coming back
- Lint mutable defaults with flake8-bugbear B006
- Test two independent calls in every stateful helper
- Keep request and tenant state in explicit objects
- Bound and instrument intentional caches
- Review function defaults as module-level state
FAQ
Is using None always safe?
It is safe when None cannot itself mean a caller-supplied value. If it is valid input, use a private sentinel object.
Can tuples be defaults?
Immutable tuples are safe, but a tuple containing a mutable object can still expose shared mutation.
Why not copy the default?
Copying can work, but it hides the surprising API and may copy nested state incorrectly. Allocate from an explicit sentinel.
Related
Other errors engineers hit next to this one
- set -e script continues after a failed pipeline
- An unquoted variable turns one argument into several
- 502 Bad Gateway from a reverse proxy
- 504 Gateway Timeout
- CORS preflight: missing Access-Control-Allow-Origin
- 413 Payload Too Large
- 429 Too Many Requests and Retry-After
- nginx 499 client closed request