Python

Mutable default argument retaining state across calls

Written and reviewed by Sahil Srivastav

PythonLanguage semanticsState bug
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

  1. 1List, dict, set, or custom object as a default. The object is created once and reused whenever the caller omits the parameter.
  2. 2Default used as an implicit cache. A convenience pattern becomes unbounded process state.
  3. 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))
PY

Step 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=last

Step 3

Search signatures

Review mutable literals and constructors in defaults.

rg -n 'def .*=[\[\]{}]|def .*=(dict|list|set)\(' src tests

The 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 bucket

How 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

Practise production debugging in a real repository

Reading about a failure and reproducing one are different skills. Gronex ships broken backend repositories with failing test suites that encode the real invariant, so you debug from evidence instead of memorising symptoms.

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

Full error and symptom index →