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 provide name, wants the default
  • create(name=None) — caller explicitly wants name to be None

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 _SENTINEL is True, so we build the default
  • create(name=None)name is _SENTINEL is False, name stays None
  • create(name="Alice")name is _SENTINEL is False, name stays "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 call

But 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:

CallIntent
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 None as 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:

  • None always means “not provided” (just use None as 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