Null Object & Sentinels
None often means both "missing" and "empty value," which causes bugs. Sentinels are unique marker objects for "not provided." Null objects implement the same interface as real collaborators but do nothing - callers skip if obj is not None checks.
Recipe
Quick-reference recipe card - copy-paste ready.
from typing import Any
MISSING: Any = object()
def lookup(key: str, default=MISSING):
try:
return cache[key]
except KeyError:
if default is MISSING:
raise
return defaultWhen to reach for this:
- Distinguish missing from None - optional fields where
Noneis valid data - Default mutable args - never use
[]or{}; useNoneor a sentinel - Null object - no-op logger, mailer, or cache for disabled features
- API boundaries -
default=MISSINGvsdefault=Nonesemantics - Enum.NONE - named sentinel in typed APIs
Working Example
A sentinel distinguishes unset config from explicit None; a null logger skips I/O.
from dataclasses import dataclass
from typing import Any, Protocol
UNSET: Any = object()
@dataclass
class Config:
timeout: float | None | object = UNSET
def resolve_timeout(cfg: Config, fallback: float = 30.0) -> float:
if cfg.timeout is UNSET:
return fallback
if cfg.timeout is None:
raise ValueError("timeout explicitly disabled")
return float(cfg.timeout)
class Logger(Protocol):
def info(self, msg: str) -> None: ...
@dataclass
class NullLogger:
def info(self, msg: str) -> None:
pass
@dataclass
class StdoutLogger:
def info(self, msg: str) -> None:
print(msg)
def run_job(logger: Logger | None = None) -> None:
log = logger if logger is not None else NullLogger()
log.info("starting")
log.info("done")
print(resolve_timeout(Config())) # 30.0
print(resolve_timeout(Config(timeout=5.0))) # 5.0
run_job() # silent
run_job(StdoutLogger())What this demonstrates:
UNSETis not equal to any user value, includingNone- Explicit
Nonecan mean "disabled" with different behavior than unset NullLoggerimplements the interface with no-ops- Optional dependency defaults to null object at runtime
Deep Dive
How It Works
object()creates a unique identity;ischecks are O(1) and unambiguousenum.Enummembers can serve as named sentinels in public APIs- Null object pattern: substitute a do-nothing implementation instead of branching
dataclasses.MISSING(3.13+) documents field state in some APIs- Type checkers need
Literalor overloads when sentinels affect return types
Sentinel vs None vs Optional
| Value | Meaning |
|---|---|
UNSET | Caller did not pass the argument |
None | Caller passed empty/absent semantic value |
Optional[T] | Value may legitimately be None |
Python Notes
from enum import auto, Enum
class _Sentinel(Enum):
MISSING = auto()
MISSING = _Sentinel.MISSING # named repr in debug outputGotchas
- Sentinel leaks in serialized data - JSON cannot represent
object(). Fix: convert tonullor omit key at API boundary. - Equality checks with
==- sentinels must useis, not==. Fix: document and lint foris UNSET. - Overusing null objects - masking real bugs when dependency should be required. Fix: require non-null deps in production wiring.
- Mutable sentinel singletons - never attach state to the sentinel object. Fix: use immutable
enummembers. - Confusing
default=Noneanddefault=UNSET- API docs must state semantics clearly. Fix: name parameterstimeout_or_unset.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
Optional only | None is the only missing semantics | Missing vs empty must differ |
**kwargs only | Truly optional extension fields | Core params need positional clarity |
NotRequired TypedDict | JSON-like partial dicts | Function parameters |
| Raise on missing | Required values | Optional with three states |
FAQs
Why not use None for missing and missing only?
When None is valid data (nullable database column), you need a third state for "argument not passed."
Is object() the standard sentinel?
Common in stdlib patterns (getattr(obj, name, default) uses its own sentinel internally). Public APIs often prefer named enums for clarity.
What is a null object?
A class implementing the same interface as real services but performing no work - avoids None checks at call sites.
How do type checkers handle sentinels?
Use overload on functions where return type depends on whether default was provided, or union T | Literal[_MISSING].
Can Pydantic use sentinels?
Use model_fields_set to detect unset fields instead of custom sentinels - Pydantic v2 tracks what was provided.
Default mutable arguments?
Never def f(items=[]). Use None and items = [] if items is None else items, or a sentinel plus factory.
Sentinel vs Ellipsis (...)?
... appears in slicing and stub files. Rarely use as runtime sentinel - object() or Enum is clearer.
How do I test sentinel paths?
Assert behavior when param omitted, when None passed, and when real value passed - three distinct cases.
Null object vs optional dependency?
Null object always callable; optional dependency may skip feature entirely. Pick based on call-site branching cost.
Does functools.lru_cache need sentinels?
Distinguish "not cached" from cached None using a private sentinel in wrapper implementations - same pattern.
Related
- EAFP vs LBYL - handling missing keys
- Dependency Injection in Python - null object as default dep
- Factory & Builder Patterns - optional fields in builders
- Pythonic Patterns Basics - section overview
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.