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. Function calls in default arguments are also evaluated exactly once at definition time, freezing the result. import time def log_event(msg, timestamp=time.time()): print(f"{msg} at {timestamp}") log_event("Start") # Start at 1700000000.0 log_event("End") # End at 1700000000.0 This dynamic default pitfall is covered fully in `functions`.
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 `dict`s at the end.
Continue learning
Previous: Mutable vs immutable objects
Previous: Primitive types: int, float, bool, str
Next: Defining functions and return values
Next: Shallow vs Deep Copy