
Default Argument Pitfalls in Python: The Mutable Default Trap
Almost every Python developer hits this bug once. You write a function with a list as its default argument, call it a few times, and the list starts remembering values from earlier calls. Nothing in the code looks wrong, there's no global variable in sight, and yet state leaks from one call to the next.
The cause is a single rule about when Python evaluates default values. Once you know it, the bug stops being mysterious, and you can spot it in code review in a second. In this post I'll walk through the rule, show the places the trap hides (including classes and dataclasses), cover the standard fixes like the None sentinel and default_factory, and point out a couple of cases where the "trap" is actually used on purpose.
The Bug in Five Lines
Here's the classic version:
# cart.py
def add_item(item, items=[]):
items.append(item)
return items
print(add_item("apple"))
print(add_item("banana"))
print(add_item("cherry", []))
print(add_item("date"))
You'd probably expect each call without a second argument to start from an empty list. That's not what happens:
['apple']
['apple', 'banana']
['cherry']
['apple', 'banana', 'date']
The second call returns both fruits. The third call passes its own list, so it's isolated. The fourth call goes back to the default and finds "apple" and "banana" still sitting in it.
Why It Happens: Defaults Are Evaluated Once
Python evaluates default argument expressions once, when the def statement runs, not each time the function is called. The resulting objects are stored on the function object itself. Every call that doesn't supply that argument gets the same object.
You can see this directly. A function's positional defaults live in __defaults__:
print(add_item.__defaults__)
(['apple', 'banana', 'date'],)
There's exactly one list, and it's attached to add_item for as long as the function exists. When the body calls items.append(item), it mutates that shared list in place.
You can also prove the timing with a default that has a side effect:
def show_default(x=print("evaluating default")):
return x
print("function defined")
evaluating default
function defined
The print in the default runs while the function is being defined, before anything calls it. It never runs again.
Mutation vs. Rebinding
The trap only bites when you mutate the default. If you rebind the parameter name to a new object, the default is untouched:
def add_one(n=0):
n = n + 1 # rebinding: creates a new int, default is unaffected
return n
Integers, strings, tuples, and None are immutable, so you can't mutate them even if you try. That's why n=0 or name="" is always safe, while items=[], options={}, and seen=set() are the risky ones. If the difference between mutating an object and rebinding a name is fuzzy, the post on variables and mutability goes through it in depth.
Keyword-Only Defaults Too
Keyword-only parameters (the ones after *) have the same behavior. Their defaults are stored in __kwdefaults__ instead:
def tag(name, *, labels=[]):
labels.append(name)
return labels
tag("a")
tag("b")
print(tag.__kwdefaults__)
{'labels': ['a', 'b']}
The Standard Fix: Use None as a Sentinel
The idiomatic fix is to default to None and create a fresh object inside the function:
def add_item(item: str, items: list[str] | None = None) -> list[str]:
if items is None:
items = []
items.append(item)
return items
print(add_item("apple"))
print(add_item("banana"))
['apple']
['banana']
Now the [] expression runs on every call that needs it, so each call gets its own list. The type hint list[str] | None tells readers and type checkers that None is an accepted value, and the if items is None check narrows it back to list[str] for the rest of the body.
Don't Use or as a Shortcut
You'll often see this shorter version:
def add_item(item, items=None):
items = items or []
items.append(item)
return items
It works for the default case, but it has a subtle bug. items or [] treats any falsy value as missing, and an empty list is falsy. If a caller passes their own empty list expecting it to be filled, the function silently replaces it:
mine = []
add_item("x", mine)
print(mine)
[]
The caller's list never received "x". Always use an explicit is None check when the parameter is a container. The truthiness post covers why empty containers are falsy and where else that catches people out.
When None Is a Valid Value: A Custom Sentinel
Sometimes None is a meaningful argument and you need to tell "not passed" apart from "passed None". Create a unique private object and compare against it by identity:
_MISSING = object()
def get_setting(name: str, default: object = _MISSING) -> object:
settings = {"debug": None}
if name in settings:
return settings[name]
if default is _MISSING:
raise KeyError(name)
return default
print(get_setting("debug"))
print(get_setting("port", 8000))
try:
get_setting("port")
except KeyError as e:
print("KeyError:", e)
None
8000
KeyError: 'port'
object() creates a new instance that nothing else can be identical to, so default is _MISSING is true only when the caller left the argument out. This is the same technique the standard library uses internally in several places, and it's the right tool for functions like dict.get-style lookups.
It's Not Only Lists: Other Ways the Trap Shows Up
Dictionaries and Sets
Any mutable container behaves the same way:
def count(word, counts={}):
counts[word] = counts.get(word, 0) + 1
return counts
count("a")
count("b")
print(count("a"))
{'a': 2, 'b': 1}
Function Calls in Defaults
The trap isn't limited to mutable objects. Any expression in a default is evaluated once, including function calls. A timestamp default is a common example:
from datetime import datetime
def log(msg: str, when: datetime = datetime.now()) -> str:
return f"{when:%H:%M:%S.%f} {msg}"
Every call to log() without when gets the time the module was imported, not the current time. A long-running service would stamp every log line with the same moment. The fix is the same sentinel pattern:
from datetime import datetime
def log(msg: str, when: datetime | None = None) -> str:
if when is None:
when = datetime.now()
return f"{when:%H:%M:%S.%f} {msg}"
The same applies to defaults like uuid.uuid4(), random.random(), or reading a config file. If the value should be computed per call, compute it inside the function.
Class __init__ Methods
Constructors are where this bug does the most damage, because the shared object ends up stored on every instance:
class Cart:
def __init__(self, items=[]):
self.items = items
c1 = Cart()
c2 = Cart()
c1.items.append("book")
print(c2.items)
print(c1.items is c2.items)
['book']
True
Two carts that were supposed to be independent share one list. Adding to one cart adds to every cart created without an explicit argument. Use items: list[str] | None = None and build the list inside __init__, exactly as with plain functions. If you want a refresher on constructors generally, see how to use the __init__ method.
Dataclasses Catch It for You
Dataclasses refuse the most common form of the mistake outright:
from dataclasses import dataclass
@dataclass
class Bad:
items: list = []
ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory
The check covers list, dict, and set (more precisely, any unhashable default). The fix is field(default_factory=...), which calls the factory each time an instance is created:
from dataclasses import dataclass, field
@dataclass
class Order:
items: list[str] = field(default_factory=list)
o1 = Order()
o2 = Order()
o1.items.append("pen")
print(o1, o2)
Order(items=['pen']) Order(items=[])
Pydantic models and attrs classes have their own equivalents (Field(default_factory=list) and attrs.Factory(list) respectively). Pydantic is more forgiving than dataclasses here: it copies a mutable default for each instance, but default_factory still makes the intent clearer.
Prefer Immutable Defaults When You Can
If a function only reads a sequence and never modifies it, you don't need None at all. An empty tuple is immutable, so it's a perfectly safe default:
from collections.abc import Sequence
def total(values: Sequence[int] = ()) -> int:
return sum(values)
print(total(), total([1, 2, 3]))
0 6
Typing the parameter as Sequence[int] instead of list[int] also documents that the function won't mutate it, and lets callers pass lists, tuples, or ranges.
For a mapping of constant defaults, you can expose a read-only view with types.MappingProxyType and copy it when you need a mutable version:
from types import MappingProxyType
DEFAULT_HEADERS = MappingProxyType({"Accept": "application/json"})
def build_headers(extra: dict[str, str] | None = None) -> dict[str, str]:
headers = dict(DEFAULT_HEADERS)
if extra:
headers.update(extra)
return headers
print(build_headers())
print(build_headers({"X-Trace": "1"}))
{'Accept': 'application/json'}
{'Accept': 'application/json', 'X-Trace': '1'}
Any attempt to write to DEFAULT_HEADERS directly raises TypeError: 'mappingproxy' object does not support item assignment, so an accidental mutation fails loudly instead of corrupting shared state.
Note that if extra: is fine here, unlike the or shortcut earlier: an empty extra dict has nothing to merge, so treating it like None changes nothing.
When the "Trap" Is Used on Purpose
Because defaults persist, people have historically used them deliberately.
Early Binding in Loops and Lambdas
The best-known legitimate use fixes the late-binding closure problem:
funcs = [lambda: i for i in range(3)]
print([fn() for fn in funcs])
funcs = [lambda i=i: i for i in range(3)]
print([fn() for fn in funcs])
[2, 2, 2]
[0, 1, 2]
The first set of lambdas all look up i when they're called, after the loop has finished, so they all see 2. In the second set, i=i captures the current value at definition time, which is exactly the "evaluated once" behavior that causes the mutable default bug. Here it's what you want. functools.partial is often a clearer way to express the same thing.
A Poor Man's Cache
You'll sometimes see a mutable default used as a cache:
def fib(n, _cache={}):
if n in _cache:
return _cache[n]
result = n if n < 2 else fib(n - 1) + fib(n - 2)
_cache[n] = result
return result
print(fib(30))
832040
It works, but it's a hidden global with a confusing signature: callers can see and pass _cache. functools.cache (or lru_cache with a size limit) does the same job with a clean signature and proper tooling like cache_info() and cache_clear(). Reach for that instead.
Catching It Automatically
You shouldn't have to rely on memory or code review. Linters flag mutable defaults reliably:
- Ruff has rule
B006(mutable argument default) andB008(function call in default argument), both from the flake8-bugbear set. Enable them with theBselector. - Pylint reports
W0102(dangerous-default-value).
A minimal Ruff configuration:
# pyproject.toml
[tool.ruff.lint]
extend-select = ["B"]
With that in place, the original add_item gets flagged before it's ever committed. The upcoming post on linting and formatting with Ruff covers the rest of the setup.
A Quick Checklist
When you write or review a function signature, ask:
- Is any default a
list,dict,set, or other mutable object? Replace it withNone(or an immutable equivalent like()) and build the object inside. - Is any default a function call (
datetime.now(),uuid4(),load_config())? Move the call into the body. - Does the code use
x = x or []? Switch toif x is Noneso caller-supplied empty containers aren't discarded. - Is
Noneitself a meaningful argument? Use a privateobject()sentinel. - Is it a dataclass or Pydantic model? Use
default_factory.
Conclusion
The mutable default trap comes down to one fact: default expressions run once, when the function is defined, and the resulting object is reused by every call. Mutating that object leaks state between calls, and calling a function in a default freezes its result at import time.
Default to None with an is None check, use default_factory in dataclasses, prefer immutable defaults when the function only reads its input, and turn on Ruff's B006 and B008 so the bug never lands in your codebase in the first place.


