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:
class _Auto:
def __bool__(self):
return False
Auto: Any = _Auto()
def create_record(name: str, tags=Auto):
tags = tags or [] # Elegant one-liner!
# ...
But this has a critical flaw. Watch what happens:
record = create_record("example", tags=[])
# Because bool([]) is False, the condition creates a NEW list!
# The empty list we passed is ignored!
This violates the principle of least surprise. When I discussed this on our podcast, other developers found it confusing, and it turned out to confuse LLMs as well. Then a podcast listener wrote in with an observation about this exact flaw (thank you!) and suggested using simple object() sentinels with identity checks instead.
Two Better Alternatives
Option 1: Simple Object Sentinels
This approach was suggested by the helpful podcast listener who wrote in.
MISSING: Any = object()
def process_data(data: list[str] = MISSING) -> list[str]:
if data is MISSING:
data = []
return data
Pros:
- No dependencies
- Can distinguish from
None
- Type checkers narrow cleanly with
is
check
Cons:
- Poor repr:
<object object at 0x...>
- Still a custom pattern to explain
Option 2: typing_extensions.Sentinel
from typing_extensions import Sentinel
MISSING: Any = Sentinel("MISSING")
def process_data(data: list[str] = MISSING) -> list[str]:
if data is MISSING:
data = []
return data
This is the modern approach based on PEP 661 (currently deferred but implemented in typing_extensions
).
Note: Pyright expects type[MISSING]
or just MISSING
in the type annotation.
Pros:
- Clean repr:
MISSING
instead of memory address - Part of proposed PEP 661
- Survives pickling;
copy()
returns same object by identity - Type checkers that support it narrow cleanly
Cons:
- Requires
typing_extensions >= 4.14.0
(released June 2, 2025) - Still experimental (PEP 661 is deferred)
- mypy support pending
Recommendations
Stick with None
defaults unless None
is genuinely ambiguous in your API. Your users may not have the latest typing_extensions
.
For Application Code
- If
None
works: Use it. The| None
annotations are accurate. - If you can't use dependencies: Use
object()
sentinels with identity checks. - If you can require
typing_extensions >= 4.14.0
: UseSentinel
for the best experience.
Always Use Identity Checks
Regardless of which sentinel approach you choose, always use is
for comparison, never truthiness checks or ==
.
Conclusion
Python's mutable default gotcha leads most developers to the None
pattern, which works well until None
itself becomes meaningful. When that happens, sentinel values provide a clean solution.
Sometimes the best code isn't the cleverest - it's the code that clearly expresses intent while working well with the entire ecosystem.