Every Python developer eventually writes a function like this, watches it misbehave in a way that makes no sense, and joins the club:
def add_item(item, basket=[]):
basket.append(item)
return basket
print(add_item("apple")) # ['apple']
print(add_item("banana")) # ['apple', 'banana'] <- wait, what?
You expected ['banana']. Instead the second call remembers the first one. Nothing about the code looks wrong at a glance, which is exactly why this bites so many people, including plenty who’ve been writing Python for years. I’ve shipped this bug myself, in a function that collected validation errors, and didn’t notice until a support ticket showed one request’s errors leaking into an unrelated request.
Why this happens
The key thing to internalize: default argument values are evaluated once, when the def statement runs, not every time the function is called.
A def statement executes exactly once, when the module is imported (or the script runs). At that moment, Python evaluates the default value expressions and stores the results as part of the function object. Every subsequent call that doesn’t supply that argument reuses the exact same object.
For immutable defaults like 0, None, or "apple", this is invisible, because you can’t mutate an int or a string in place anyway. Any “change” creates a new object and rebinds the name. But for mutable defaults, list, dict, set, or any custom mutable object, that single shared object sits there across every call, and if you mutate it (.append, .update, [key] = value), the mutation persists.
You can prove it’s the same object with id():
def add_item(item, basket=[]):
basket.append(item)
return basket
a = add_item("apple")
b = add_item("banana")
print(a is b) # True
print(id(a) == id(b)) # True
a and b aren’t just equal, they’re the same list. You can also see it directly on the function object itself, since defaults live in __defaults__:
print(add_item.__defaults__) # (['apple', 'banana'],)
That tuple is the same one Python built when it first saw the def. It never gets rebuilt.
The fix: default to None, create fresh inside
The standard idiom sidesteps the problem by never putting a mutable object in the default at all. Use None as a sentinel and create the real value inside the function body, where it’s evaluated fresh on every call:
def add_item(item, basket=None):
if basket is None:
basket = []
basket.append(item)
return basket
print(add_item("apple")) # ['apple']
print(add_item("banana")) # ['banana']
Now each call that omits basket gets its own list, because basket = [] runs as part of the function body, not as part of the def statement.
This pattern generalizes to dicts, sets, and any other mutable default:
def record_event(name, seen=None):
if seen is None:
seen = {}
seen[name] = seen.get(name, 0) + 1
return seen
When the “bug” is actually what you want
It’s worth knowing this isn’t always a mistake, sometimes people rely on the shared-object behavior deliberately, as a cheap memoization cache:
def fibonacci(n, _cache={0: 0, 1: 1}):
if n not in _cache:
_cache[n] = fibonacci(n - 1) + fibonacci(n - 2)
return _cache[n]
Here the mutable default persists across calls on purpose, and the leading underscore signals “internal, don’t pass this in.” I’m not a fan of this style for anything beyond a script or a coding exercise, because it’s easy for a future reader (including future you) to mistake it for the bug rather than the feature. If you want a cache, functools.lru_cache or an explicit module-level dict says the same thing without relying on a Python quirk to communicate intent:
from functools import lru_cache
@lru_cache(maxsize=None)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
Real-world pattern: catching it before it ships
Both pylint and ruff flag this by default. Ruff’s rule is B006 (from flake8-bugbear), and it’s on by default in most configs:
$ ruff check .
example.py:1:29: B006 Do not use mutable data structures for argument defaults
If your project runs a linter in CI, you likely already have a safety net for this specific mistake. It’s still worth understanding the mechanism though, because the same “default evaluated once” behavior shows up in less obvious places, like a default value that calls a function:
import datetime
def log_event(message, timestamp=datetime.datetime.now()):
print(f"[{timestamp}] {message}")
Every call to log_event prints the same timestamp, the one captured when the module was imported, no matter when you actually call it. No linter flags this one by default because datetime.now() doesn’t look like a mutable default, it’s the same “evaluated once” trap wearing a different costume. The fix is identical: default to None and call datetime.datetime.now() inside the function.
def log_event(message, timestamp=None):
if timestamp is None:
timestamp = datetime.datetime.now()
print(f"[{timestamp}] {message}")
Summary
- Default argument values are evaluated exactly once, when the
defstatement runs, and the resulting object is reused on every call that omits that argument. - This is harmless for immutable defaults but dangerous for mutable ones (lists, dicts, sets, custom mutable objects), since mutations accumulate silently across calls.
- The fix: default to
None, then create the real mutable value inside the function body so it’s fresh on every call. - The same “evaluated once” behavior applies to any expression in a default, not just mutable literals, watch out for things like
datetime.now()as a default. - Occasionally the shared-state behavior is used intentionally as a poor man’s cache, but prefer
functools.lru_cacheor an explicit cache object so the intent is obvious to readers. - Linters like
ruff(B006) andpylintcatch the classic mutable-literal case automatically, turn them on if you haven’t.