The Problem
Python’s None is often used as a default argument to mean “not provided”. But what if None is a valid value the caller might want to pass?
def create(*, name: str | None = None):
if name is None:
name = "default name"
...Here, there’s no way to distinguish between:
create()— caller didn’t providename, wants the defaultcreate(name=None)— caller explicitly wantsnameto beNone
Both hit the same if name is None branch.
The Solution: A Sentinel Object
Create a unique object that can never collide with any real value:
_SENTINEL = object()
def create(*, name: str | None = _SENTINEL):
if name is _SENTINEL:
name = "default name" # caller didn't pass anything
# if name is None, caller explicitly wanted None
...Now the three cases are distinguishable:
create()—name is _SENTINELisTrue, so we build the defaultcreate(name=None)—name is _SENTINELisFalse,namestaysNonecreate(name="Alice")—name is _SENTINELisFalse,namestays"Alice"
Why object() Works
object() creates a unique instance. No other value in Python will ever be is-equal to it:
_SENTINEL = object()
_SENTINEL is None # False
_SENTINEL is True # False
_SENTINEL == "anything" # False
_SENTINEL is _SENTINEL # True (only identity match)It’s private by convention (leading underscore) — callers should never pass it directly.
Why Not Use a Function Call as a Default?
Python evaluates default arguments once, at function definition time, not per call:
def create(*, items: list = []):
items.append("x")
return items
create() # ["x"]
create() # ["x", "x"] — same list object!So def create(*, score: Score = make_default_score()) would share one Score instance across every call. For mutable objects (lists, dicts, Pydantic models), this causes subtle bugs where one caller’s modifications affect all subsequent callers.
The standard workaround is None + build-in-body:
def create(*, items: list | None = None):
if items is None:
items = [] # fresh list every callBut this brings us back to the original problem — None now means “build default” and you can’t pass actual None.
Real Example: CompanyFactory
In our CompanyFactory, both financials and score need three states:
| Call | Intent |
|---|---|
CompanyFactory.create() | Auto-generate default financials and score |
CompanyFactory.create(financials=None) | Company with no financials |
CompanyFactory.create(financials=my_financials) | Use the provided financials |
Implementation:
from typing import Any
_SENTINEL: Any = object()
class CompanyFactory:
@staticmethod
def create(
*,
financials: Financials | None = _SENTINEL,
score: Score | None = _SENTINEL,
...
) -> Company:
if financials is _SENTINEL:
financials = Financials(yearly_financials=[...])
# if financials is None, it stays None
if score is _SENTINEL:
score = CompanyFactory._make_default_score()
# if score is None, it stays None
return Company(financials=financials, score=score, ...)The _SENTINEL: Any type annotation avoids type checker complaints about object not being compatible with Financials | None.
When to Use It
Use the sentinel pattern when:
- A parameter’s type includes
Noneas a valid value - You need to distinguish “not provided” from “explicitly
None” - Factories, builders, and update/patch functions are common use cases
Don’t bother when:
Nonealways means “not provided” (just useNoneas default)- The parameter type doesn’t include
None
Alternatives
- Boolean flag:
create(no_score=True)— works but clutters the API - Overloads: Multiple function signatures — verbose, hard to maintain
- typing.MISSING / dataclasses.MISSING: Some libraries provide their own sentinels (e.g.
attrs.NOTHING,dataclasses.MISSING) — same concept, just pre-built
Further Reading
- Python docs: Default Argument Values
- PEP 661 (draft): Sentinel Values — a proposal to standardize sentinels in Python
- https://python-patterns.guide/python/sentinel-object/