None is probably the most overloaded value in Python. We use it as a Swiss Army knife to express that something is missing, unset, that an error occurred, or simply as an actual value. In my talk about sentinel values, which I presented this year at PyConDE/PyData Berlin 2026, EuroPython 2026 and, most recently, the Swiss Python Summit 2026, I take a closer look at why this ambiguity bites us and what to use instead.

The problem shows up in many places. A simple cache returns None both for a missing key and for a key that maps to None. Does timeout=None in a configuration update mean “no timeout” or “keep the current value”? And a function that swallows a TimeoutError and returns None uses it as an error signal. Sentinel values resolve this by being a guard outside the domain of valid values, so they can never collide with an actual value.

Python itself relies heavily on sentinels, hidden in plain sight: Ellipsis skips dimensions in NumPy slicing and marks stubs, and NotImplemented means “find the implementation somewhere else”, which enables double dispatch for binary operators via methods like __rsub__. Pydantic’s MISSING distinguishes a field that was explicitly set to null from one that was never sent at all, and it has just left the experimental stage in Pydantic 2.14.

Things get tricky once you create your own sentinels. The classic MISSING = object() works perfectly at runtime but breaks type narrowing in static type checkers. Workarounds like TypeIs guards, magic Literal strings or the single-member Enum trick all come with trade-offs, and identity checks break when sentinels are copied or pickled.

Then came a plot twist. At PyConDE in April, I had to conclude that PEP 661, the proposal to standardize sentinels, was deferred. Only nine days later, the Steering Council accepted it. Since Python 3.15, sentinel is a built-in, and type checkers can narrow it just like None:

MISSING = sentinel("MISSING")

def update_email(user_id: int, email: str | None | MISSING = MISSING) -> None:
    if email is MISSING:
        return  # nothing to update
    elif email is None:
        db.delete_email(user_id)
    else:
        db.set_email(user_id, email.lower())  # narrowed to str

If you are on an older Python version, typing_extensions provides a backport. Type-checker support is still catching up, so check your type checker before relying on it.

My main takeaway: be aware of the ambiguity of None and think about what you actually want to express. Use sentinels to resolve the ambiguity, prefer the ones your library already provides, and replace the old object() and Enum hacks with the new built-in. If you are tempted to return None instead of raising an exception, my talk on exceptions and result types might also interest you.

Here are the slides from PyConDE, which still describe PEP 661 as deferred. The EuroPython recording below is the most up-to-date version of the talk and covers the new sentinel built-in.


Comments

comments powered by Disqus