Cached at:
10/31/26, 09:22 PM
# Much Ado About None: Sentinel Values in Python
Source: [https://florianwilhelm.info/2026/10/sentinel_values/](https://florianwilhelm.info/2026/10/sentinel_values/)
`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](https://2026.pycon.de/talks/88TTRY/),[EuroPython 2026](https://ep2026.europython.eu/session/the-problem-with-none-sentinel-values-and-the-semantics-of-absence/)and, most recently, the[Swiss Python Summit 2026](https://talks.python-summit.ch/swiss-python-summit-2026/talk/JHJ3XY/), 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](https://pydantic.dev/articles/pydantic-v2-14-release)\.
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[PEP661](https://peps.python.org/pep-0661/), the proposal to standardize sentinels, was deferred\. Only nine days later, the Steering Council accepted it\. Since Python 3\.15,[`sentinel`](https://docs.python.org/3/library/functions.html#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](https://typing-extensions.readthedocs.io/en/latest/#typing_extensions.sentinel)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](https://florianwilhelm.info/2025/04/evolution_of_goto/)might also interest you\.
Here are the[slides](https://florianwilhelm.info/documents/sentinel_values.pdf)from PyConDE, which still describePEP661 as deferred\. The EuroPython recording below is the most up\-to\-date version of the talk and covers the new`sentinel`built\-in\.
---