Python Mutable Default Arguments: Debug the Shared List
Reproduce shared default-list state, choose a fix and test who owns the resulting collection.
A function that should start with an empty list can appear to remember an earlier call. Before changing unrelated code, reproduce the problem with two calls in a fresh Python process. This guide follows the object that stores the values, then compares two fixes with different ownership rules.
You need basic functions, lists and the difference between comparing values and checking identity. The runnable examples.py companion uses the standard library only.
Reproduce the unexpected state
Run this block in a fresh interpreter:
def collect(label, labels=[]):
labels.append(label)
return labels
first = collect("API")
print(first) # ['API']
second = collect("UI")
print(second) # ['API', 'UI']
print(first) # ['API', 'UI']
print(first is second) # True
Read the three list outputs in order. The first print happens before the second call. After the second call, printing first shows an additional value. Saving a returned list in a variable did not create a snapshot; first and second refer to the same list in this experiment.
This detail matters when debugging logs. An earlier printed representation can show a shorter list even though the variable now points to an object containing more values. Inspect when each observation was made.
Trace the default across the calls
Python evaluates a default expression when execution reaches the function definition. Here that creates one list. Calls that omit labels use that object, and append changes its contents. Executing a new function definition creates a new default; the sharing described here concerns calls to the same function object.
The diagram uses separate panels for the original function and the repaired function. Within the shared-default panel, both calls point to one object; it is not a claim about a specific memory address.
For this small demonstration you can inspect:
print(collect.__defaults__[0] is first) # True
Treat this as a diagnostic observation. Editing __defaults__ is not a suitable repair for ordinary application code.
Create a fresh list when the argument is omitted
If every omitted argument should begin an independent collection, allocate inside the function:
def collect_fresh(label, labels=None):
if labels is None:
labels = []
labels.append(label)
return labels
left = collect_fresh("API")
right = collect_fresh("UI")
print(left) # ['API']
print(right) # ['UI']
print(left is right) # False
None is an absence marker in this contract. The function explicitly replaces it with a new list. This is appropriate when None does not need a separate business meaning. When omitted and explicitly supplied None must mean different things, use a dedicated sentinel and document those two cases.
Do not replace the condition with if not labels. An explicitly supplied empty list is falsey too, so that test can silently change which object the function updates.
Decide what happens to a caller-supplied list
The repaired function still appends to a list supplied by its caller:
existing = []
returned = collect_fresh("API", existing)
print(existing) # ['API']
print(returned is existing) # True
That behavior can be useful when the caller intentionally owns an accumulator. It can also surprise a caller expecting a new result. Name and document the ownership rule instead of assuming the None pattern prevents every kind of mutation.
For a function that returns a new outer list, copy the incoming iterable before appending:
def collect_copy(label, labels=None):
result = [] if labels is None else list(labels)
result.append(label)
return result
original = ["start"]
copied = collect_copy("API", original)
print(original) # ['start']
print(copied) # ['start', 'API']
print(copied is original) # False
This is a shallow copy. If the iterable contains nested mutable objects, their references remain shared; the function has not promised a recursive copy. The example only appends a new outer element.
Add checks that express the intended contract
Use a fresh function definition when repeating the original bug demonstration. Otherwise previous interactive calls may already have changed its default list.
For independent omitted calls, check both value and identity:
a = collect_fresh("one")
b = collect_fresh("two")
assert a == ["one"]
assert b == ["two"]
assert a is not b
Also test supplied-empty-list behavior and whether caller data should change. Those cases distinguish the intended API from a repair that merely passes the first demonstration. examples.py includes checks for the original failure, both repairs and the shallow-copy boundary.
If these checks fail inside a larger project, isolate imports, repeated notebook execution and shared fixtures before concluding that Python behaves differently. Re-run the minimal example in a fresh process and record the interpreter version with the result.
Do immutable defaults need the same treatment?
An immutable value such as a number cannot be changed by a list-style append. Rebinding a local parameter is different from mutating an object. However, a tuple can contain a mutable item, so the outer type alone does not settle every ownership question.
Intentional shared state is possible. When sharing is required, an explicitly owned object passed by the caller usually makes that decision easier to see and test.
Practice and related reading
Change the list example into a dictionary of labels and counts. Predict the second call before running it, write the expected output, then implement independent-call behavior. Include a test where the caller supplies an empty dictionary.
For a wider study sequence, use the Python developer roadmap. For writing checks and reporting a reproducible failure, continue with software testing basics.
Sources
Language rule and the absence-marker pattern: Python tutorial: default argument values, checked 16 October 2026. The examples and regression checks in this package were authored for this lesson.