Mutable default argument pitfall

RoadmapsPython

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

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

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

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

Return to Python Roadmap