Best practices: avoid mutable default arguments

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 (check B006) warns on mutable defaults.
  • Pylint — emits dangerous-default-value warnings.
  • 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 None and initialize inside
  • βœ… Use field(default_factory=...) for dataclasses
  • βœ… Run flake8-bugbear or ruff in 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: