Mutable default argument pitfall
Overview
Python evaluates default argument values once when the function is defined — not each time the function is called. If that default is a mutable object (list, dict, or set), all calls that omit the argument share the SAME object in memory. Example of the bug: def append_tag(tag, result=[]): result.append(tag) return result Calling append_tag('a') then append_tag('b') returns ['a', 'b'] on the second call — the list was mutated between calls and was never reset. The idiomatic fix is to use None as the default sentinel and create the mutable object inside the body: def append_tag(tag, result=None): if result is None: result = [] result.append(tag) return result Think of the default as a sticky note stapled to the function when Python first reads its definition — every call reads the same note, not a fresh copy printed for each call.
This bug is invisible at a glance: the signature looks correct and the very first call always works. The symptom is mysterious state persistence across calls — data from one request leaking into the next in API handlers, or factory functions producing objects that share hidden state.
Where used: FastAPI dependency factories, Pydantic model_validator helpers, argument accumulator patterns in CLI tools
Why learn this
- Prevents a persistent cross-call mutation bug in any function that uses a list or dict as a default parameter
- A canonical Python interview question — examiners probe this explicitly with 'why does this function remember values between calls?'
Code walkthrough
def add_field(key, store={}):
store[key] = True
return store
print(add_field('name'))
print(add_field('email'))
print(add_field('age'))
Focus: store is created once at function definition; each call without an explicit argument mutates the same dict, so the printed output grows with every call — the bug in action.
Common mistakes
- Using a list or dict directly as a default: def process(items=[]) mutates the same list across all calls that omit items. Fix: use None and initialize inside the body with if items is None: items = [].
- Thinking the bug only affects lists: Any mutable default — dict, set, custom object — has the same problem. Even datetime.now() as a default captures the startup time, not the call time.
- Guarding with `or` instead of `is None`: Writing result = result or [] silently replaces a valid explicitly-passed empty list. Use if result is None: to guard only the missing-argument case.
Glossary
- sentinel
- A special, unique value (like None) used to indicate that no data was provided or to signal the end of a process, e.g. `def process(items=None): if items is None: items = []`.
- cross-call
- Events or data that carry over from one execution of a function to the next time that same function is called, e.g. a shared mutable default list that retains items between calls.
Recall questions
- When are default argument values evaluated in Python?
- Why is using a mutable object as a default argument dangerous?
- What is the idiomatic fix for the mutable default argument pitfall?
- Why is `result = result or []` an unsafe guard for this fix?
Understanding checks
A developer expects this function to print the current time each time it's called. What actually happens?
The time is recorded once when the function is defined. Every call that omits `t` will print the exact same timestamp.
In Python, default arguments are evaluated exactly once at function definition time, not each time the function is called. Thus, `time.time()` executes only once.
What is the output of this code?
2 3 8
The first call `multiply(2)` appends 2 to the default list and returns 2. The second call `multiply(3, [])` passes a new list, so it returns 3 without affecting the default list. The third call `multiply(4)` uses the default list again, which still holds `[2]`. It appends 4, so the list becomes `[2, 4]`, and it returns `2 * 4 = 8`.
Practice tasks
Fix a leaking tag accumulator
The function below accumulates tags across calls because of a mutable default argument. Modify the function to fix this bug so that each call without a `tags` argument starts fresh. Output should be `['admin']`, `['user']`, `['guest']`.
Challenge
Request header builder
Write add_header(key, value, headers=None) that adds key:value to a headers dict and returns it. Demonstrate that two independent call chains each produce their own dict — no shared state between them. Print both dicts at the end.
Continue learning
Previous: Mutable vs immutable types
Next: Shallow vs deep copy