Python functions sometimes behave in ways that catch even seasoned developers off guard, especially when a function unexpectedly keeps data from earlier calls without any visible state or caching. This often comes down to using mutable default arguments in function definitions.
How Mutable Defaults Lead to Shared State
Take a function meant to collect items in a list. If its signature uses a mutable object like a list as a default parameter, Python evaluates that default just once—when the function is defined. Later calls that skip the argument all use the same object. This is a documented part of Python's language specification, as detailed in the official Python language reference.
def add_item(item, items=[]):
items.append(item)
return items
print(add_item("a")) # ['a']
print(add_item("b")) # ['a', 'b'] # Expected ['b']
print(add_item("c")) # ['a', 'b', 'c']
Every call that omits the items argument changes the same list. This can create confusing bugs, especially if the function should return a new list each time. The default object created by [], {}, or set() stays tied to the function for later calls that skip that argument, following Python’s function-definition and object-model rules.
Why Python Behaves This Way
The Python Software Foundation documentation explains that default argument values are evaluated only once at function definition. Mutable objects like lists, dictionaries, or sets stick around across calls unless the function creates a new object inside its body. The default value lives on the function object itself, visible via func.__defaults__. For example, after defining def add_item(item, items=[]):, the default list sits in the function's __defaults__ tuple and gets reused for every call that skips the items argument. As the function appends new values, the list's contents change, but the default list's identity stays the same. This explains why repeated calls can yield ['a'], then ['a', 'b'], then ['a', 'b', 'c'] without any global variable or explicit cache, as described in the Python data model documentation.
The official Python FAQ points out that this issue affects dictionaries, sets, and user-defined mutable objects too. The problem shows up when a function mutates the default, such as with .append(), assigning dictionary keys, or adding set members. The same one-time-evaluation rule can be used on purpose when persistent state is wanted, so the behavior itself is not a bug in the implementation.
Correcting the Function Definition
To avoid shared state, set None as the default and create the mutable object inside the function. This way, each call gets a new object unless the caller provides one. The official Python documentation recommends this approach, which keeps "no argument supplied" separate from an explicitly supplied empty or preexisting collection.
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
print(add_item("a")) # ['a']
print(add_item("b")) # ['b']
Always check if items is None instead of if not items. An empty list from the caller is falsy, and replacing it would break expected behavior for callers who want to use their own list. Python’s default-argument rule is different from a model where parameter defaults are re-evaluated at every call, and that difference explains why using None works. None is immutable, so it cannot gather state through operations like .append(); the function can check for it and create a new list locally. An explicitly passed list takes a separate path and is intentionally changed if the function appends to it.
Other Cases: Dictionaries and Dataclasses
This pattern affects any mutable default, including dictionaries. For example, a function like def log(event, ctx={}) will share the same dictionary across calls. In Python's dataclasses, using mutable defaults for fields like lists or dictionaries triggers a ValueError. The recommended fix is to use field(default_factory=list) or a similar factory function to ensure a new object for each instance.
Detecting and Preventing the Bug
Linters can catch this issue early. Tools like Ruff (rule B006) and flake8-bugbear flag mutable default arguments in function signatures. If a function seems to keep state unexpectedly, checking func.__defaults__ can show if a shared object is in use. Static-analysis tools and code-review guidelines often flag mutable literals in defaults because the bug may stay hidden until several calls happen. The corrected implementation creates a new list for each omitted argument while still allowing intentional accumulation when the caller passes an existing list.
Intentional Use and Safer Alternatives
Some developers use mutable defaults on purpose as a simple memoization cache. While this works, it can surprise others reading the code. Using a named module-level dictionary or the functools.lru_cache decorator makes the intent clearer and avoids confusion.
Checklist for Safe Function Definitions
- Any =[], ={}, or =set() in a function signature should be treated as a potential bug unless proven otherwise.
- Default to None and create the object inside the function.
- Always compare with is None to avoid replacing valid empty objects.
- Enable linter rules that flag mutable default arguments.
This behavior remains part of Python’s documented function semantics and has not been changed by updates to the default-argument model. The Python Software Foundation documentation and official FAQ continue to highlight this as a recurring source of surprising behavior for developers.
