Why Mutable Default Arguments Are a Hidden Source of Bugs
When defining functions in Python, one of the most notorious pitfalls is using mutable objects like lists or dictionaries as default arguments. Although this might seem harmless at first, it can lead to confusing, hard-to-detect bugsβespecially in production systems. This post dives deep into why this happens, how to avoid it, and what modern Python tools and patterns help engineers write safer, more predictable code.
Understanding the Problem
In Python, default argument values are evaluated onceβwhen the function is defined, not each time it is called. This behavior is by design, as Python compiles the function object and stores its defaults in memory for efficiency. But if that default value is a mutable object, such as a list or dictionary, any modification to that object persists across subsequent calls to the function.
def add_item(item, container=[]):
container.append(item)
return container
print(add_item('a')) # ['a']
print(add_item('b')) # ['a', 'b'] # Surprise!
At first glance, this looks like a bug. However, Python is doing exactly what it should: the list [] was created once at definition time, and the same object is reused each call.
Visualizing the Behavior
βββββββββββββββββββββββββββββββ
β Function Definition Time β
β container = [] β
βββββββββββββββ¬ββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββ
β add_item('a') β
β container -> ['a'] β
ββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββ
β add_item('b') β
β container -> ['a','b'] β (unexpected sharing)
ββββββββββββββββββββββββββ
Why This Matters in Real Systems
This issue goes beyond toy examples. In production-grade code, such defaults can appear in API handlers, configuration functions, and data processing pipelines. Engineers at companies like Netflix and Stripe have reported subtle bugs stemming from shared mutable defaults in legacy codebases, leading to unpredictable behavior in long-running processes or when using multithreading.
Consider frameworks such as FastAPI or Flask. They often rely on function definitions to define routes and their behaviors. Using mutable defaults in request handlers or dependency injections can cause data leaks across requestsβa serious issue in web applications.
The Correct Way: Use None and Initialize Inside
The most Pythonic way to avoid this trap is to use None as a sentinel default value and create a new object inside the function body when needed.
def add_item(item, container=None):
if container is None:
container = []
container.append(item)
return container
print(add_item('a')) # ['a']
print(add_item('b')) # ['b']
Now each call creates a new list if no container is provided, ensuring isolation between invocations.
Common Mutables to Avoid
[](list){}(dict)set()- Instances of custom classes with mutable attributes
Safe Defaults: Immutables Only
Itβs safe to use immutable objects like numbers, strings, or tuples as default arguments:
def greet(name="World"):
return f"Hello, {name}!"
Deep Dive: Why This Design Exists
Guido van Rossum, Pythonβs creator, has explained that evaluating default arguments only once at function definition time was a deliberate design choice for performance and memory efficiency. This decision trades off flexibility for predictability in function definitions.
From Pythonβs perspective, the function object stores its defaults in __defaults__:
>>> def f(a, b=[]): pass
>>> f.__defaults__
([],)
This means you can even mutate it manually (not recommended):
f.__defaults__[0].append(42)
print(f.__defaults__) # ([42],)
Tools and Linters That Catch Mutable Defaults
Thankfully, modern tooling can automatically detect this class of bugs.
- Flake8 — with the plugin
flake8-bugbear(checkB006) warns on mutable defaults. - Pylint — emits
dangerous-default-valuewarnings. - Mypy — with stricter static analysis modes may highlight suspicious default values.
- Ruff — a high-performance Python linter gaining popularity (used at companies like Stripe, Astral, and Prefect) supports similar checks and is much faster than legacy tools.
Example with flake8-bugbear:
$ pip install flake8-bugbear
$ flake8 your_code.py
# Output
B006 Do not use mutable data structures for argument defaults.
Static Typing Perspective
While type hints (PEP 484) and modern typing tools like pyright or mypy do not prevent mutable default bugs, combining static typing with immutability practices (typing.Final, frozen=True dataclasses) can reduce the likelihood of mutation-driven issues.
from typing import Final
DEFAULTS: Final = (1, 2, 3)
Mutable Defaults in Data Classes
When using @dataclass, mutable defaults are another common trap. For example:
from dataclasses import dataclass
@dataclass
class User:
permissions: list[str] = [] # β bad
This will cause all instances of User to share the same list object. Instead, use default_factory:
from dataclasses import dataclass, field
@dataclass
class User:
permissions: list[str] = field(default_factory=list) # β
good
This ensures that each instance gets its own new list when constructed.
Best Practices Summary
| Do | Don't |
|---|---|
Use None and initialize inside |
Use mutable types like [] or {} as defaults |
Use field(default_factory=...) in dataclasses |
Use direct mutable defaults in dataclasses |
Enable linters like flake8-bugbear or ruff |
Ignore linter warnings about mutable defaults |
Testing for Mutable Defaults
You can create a simple unit test to detect shared state across invocations:
def test_mutable_default():
def bad_func(x, cache={}):
cache[x] = True
return cache
first = bad_func('a')
second = bad_func('b')
assert 'a' not in second # This will fail if cache is shared
Modern Patterns to Replace Mutable Defaults
Some frameworks and patterns actively prevent mutable defaults by design. For example:
- FastAPI uses
Depends()and Pydantic models to create fresh instances per request. - attrs library and Pydantic v2 both use factory-based defaults for mutable structures.
- Dataclass Transformers (an emerging library in 2025) enforces immutability and per-instance state.
Real-World Implications
Large-scale Python systems at organizations like Google and Meta use static analysis pipelines that automatically catch mutable default patterns in continuous integration. This highlights the importance of incorporating such checks early in the development lifecycle.
Mutable defaults are a form of shared global state, hidden inside a function signature. In concurrent systems, especially asynchronous or multi-threaded Python code, this can lead to race conditions and state bleed, complicating debugging and reproducibility.
Checklist for Engineers
- β Never use mutable objects as default parameters
- β
Use
Noneand initialize inside - β
Use
field(default_factory=...)for dataclasses - β
Run
flake8-bugbearorruffin CI - β Prefer immutables (tuples, frozensets) for shared defaults
- β Audit existing code for mutable default warnings
Conclusion
Mutable default arguments are a small but pervasive source of subtle bugs in Python. They often escape notice until production, causing inconsistent state or data leaks. The best practice is simple: never use mutable types as defaults. Instead, initialize inside the function or use factory methods. Combining this with strong linting, immutability discipline, and testing ensures robust, predictable behavior in your Python codebases.
References:
