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
Nonerepresents NULL - Configuration systems where
Nonemeans "inherit from parent" - Data processing where
Nonemeans "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: