Mutable Defaults in Python: When None Isn't Enough
This post walks through Python's mutable default gotcha and explores modern solutions for when the standard None
workaround isn't sufficient.
The Mutable Default Gotcha
Every Python developer eventually learns about the mutable default argument gotcha. Here's what happens:
# BAD - shared mutable default
def add_item(item, items=[]):
items.append(item)
return items
# First call works as expected
print(add_item("a")) # ['a']
# But the second call reveals the problem!
print(add_item("b")) # ['a', 'b'] - wait, what?
The issue is that default arguments are evaluated once when the function is defined, not each time it's called. So all calls to add_item()
without providing items
share the same list object!
The Standard Solution
This leads to the standard solution using None
:
def add_item(item: str, items: list[str] | None = None) -> list[str]:
if items is None:
items = []
items.append(item)
return items
This works perfectly well for most cases. The list[str] | None
annotation accurately represents what callers can pass, even though it looks a bit ugly and we immediately replace None
with an empty list internally.
When None Has Meaning
The real trouble starts when None
is a legitimate value in your domain:
- Database ORMs where
None
represents NULL - Configuration systems where
None
means "inherit from parent" - Data processing where
None
means "no value provided" (different from "empty collection")
Now you need to distinguish between "no argument provided" and "explicitly passed None
". This is where sentinel values come in.
The Search for a Better Pattern
When I read about Luke Plant's Auto
sentinel pattern, it seemed elegant. The idea was to use a falsy sentinel: