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: Use Sentinel 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.

Further Reading

Webmentions

Mentions (1)

Jochen
Jochen mentioned this post June 29, 2025

Return to blog