Python mutable default arguments: why state survives between calls
Reproduce a shared-default bug, repair it with a sentinel, and distinguish a fresh collection from intentional accumulation.
Python evaluates a default argument when the function definition executes. It does not recreate that object for every call. If the default is a mutable list or dictionary, changing it can affect later calls that omit the argument.
That behavior can mix records from otherwise unrelated operations. The repair is to create a new collection inside the call when no collection was supplied. We will also distinguish that behavior from deliberately appending into a collection owned by the caller. The following example uses invented labels and requires only the standard library.
Reproduce the retained state
Read the two calls as if they happened in separate parts of a data-processing program. Both omit labels, so both use the same default list.
def collect_bad(label, labels=[]):
labels.append(label)
return labels
first = collect_bad("train")
second = collect_bad("test")
print("shared:", first is second, first)
def collect(label, labels=None):
if labels is None:
labels = []
labels.append(label)
return labels
a = collect("train")
b = collect("test")
assert a is not b
print("fresh:", a, b)
owned = []
returned = collect("validation", owned)
assert returned is owned
print("caller-owned:", owned)
Expected output:
shared: True ['train', 'test']
fresh: ['train'] ['test']
caller-owned: ['validation']
Notice that first changes after the second call even though the code never reassigns it. Both names refer to the same object. The bug is shared identity, not a special rule about the text of a variable name.
Why the sentinel works
None is a marker for the omitted case. Inside the function, labels = [] executes on each call that uses that marker, creating a fresh list. Supplying a list takes the other path and deliberately preserves the caller's object.
Use if labels is None, rather than if not labels. An empty caller-supplied list is false in a truth test, but it is still an explicit input. Replacing it with a new list would silently change the function's ownership contract. The last assertion checks exactly that distinction.
The same issue applies to dictionary and set defaults. It can also appear with an instance of a user-defined mutable class. Immutable defaults such as a number, string or None do not acquire this accumulation behavior from an in-place mutation of the default object.
Decide whether the function should mutate inputs
Our repaired collect still mutates a list explicitly provided by the caller. That is a valid design when the purpose is accumulation, but the behavior should be documented. If the desired contract is a fresh result in every case, use result = [] if labels is None else list(labels), append into result, and return it.
That extra copy is shallow. For a list of nested dictionaries, copying the list does not isolate each dictionary. The shallow-copy walkthrough explains the next layer of references. Avoid treating one fix as a guarantee of complete isolation.
If None itself is a meaningful supplied value, use a private sentinel object instead. Set _MISSING = object(), use it as the default, and compare by identity. This lets the function distinguish “argument omitted” from “argument explicitly set to None.” Choose this only when the distinction is needed; it adds another concept for callers and maintainers.
Test independence and intentional sharing
A useful regression test should call the function more than once. One isolated call cannot expose this particular leak. Check independent omitted calls, a supplied empty list, and a supplied nonempty list. Assert the intended identity relationship as well as the returned contents.
Persistent defaults are occasionally intentional, but a hidden default list is a poor way to communicate a shared cache or registry. An explicit object, dependency or documented stateful class makes the lifetime easier to inspect and test. Shared state can also need concurrency controls; changing the default alone does not make an accumulator safe for concurrent use.
Apply the rule to a small project
Create a function that gathers parsing errors for one file. Run it on two invented files and verify the second file does not inherit the first file's errors. Then add an explicit argument that lets a caller accumulate errors across both files. Write a test for each contract before combining the function into a larger pipeline.
The Python tutorial's default-argument section explains when defaults are evaluated. Continue through Python functions and projects to connect call behavior, ownership and testable programs.