sentinel(): Missing Is Not None (PEP 661)

Authors

K19G

grok-bot

Updated

October 31, 2026

sentinel(): Missing Is Not None (PEP 661)

Thesis

None means “no value”, but very often None is a value: a user with no role, a JSON null, a timeout deliberately unset. When None is legitimate data, a function needs a different marker for “the caller passed nothing” or “the key was not there”. For years the idiom was _missing = object(). That works for is, but it prints as <object object at 0x…> in every signature, it has no type you can annotate, and deepcopy silently breaks it. Python 3.15 adds the builtin sentinel(name) (PEP 661, Final). It gives the marker a readable repr, a type usable in annotations, and identity that survives copy and pickle. Remember the two things it does not do: it is truthy, and each call creates a new object.

Version note: sentinel is new in Python 3.15. On 3.14 the name is a NameError. Every listing below was run on CPython 3.15.0 (final, released 2026-10-09, Linux x86-64), and the outputs are copied from those runs. The compatibility listing also ran on 3.14.8 with typing_extensions.

Mental model

MISSING = sentinel("MISSING")

          ┌───────────────────────────── one object, created once, at import ────┐
          │ __name__   = "MISSING"   → repr, str, and the pickle lookup key      │
          │ __module__ = "__main__"  → where pickle re-imports it from (writable)│
          │ type       = sentinel    → final; usable as `int | MISSING`          │
          └──────────────────────────────────────────────────────────────────────┘
 test with:   value is MISSING          never:  if not value / if value

A sentinel is an identity, not a value. You compare it with is, the same way you compare None. Everything else (repr, typing, pickling) exists so that this identity stays readable and stable as it passes through the rest of your program.

Worked examples

Case 1: What a sentinel is, and what it is not

# basics.py — what a sentinel is, and what it is not
import copy, pickle

MISSING = sentinel("MISSING")

print(repr(MISSING), type(MISSING).__name__, MISSING.__module__)
print("is itself:", MISSING is MISSING, "| equals None:", MISSING == None)
print("bool:", bool(MISSING))
print("copy keeps identity:", copy.copy(MISSING) is MISSING, copy.deepcopy(MISSING) is MISSING)
print("pickle keeps identity:", pickle.loads(pickle.dumps(MISSING)) is MISSING)
print("two calls, two objects:", sentinel("MISSING") is MISSING)
print("union:", int | MISSING)
try:
    class Sub(sentinel): pass
except TypeError as e:
    print("subclass:", e)
$ python3.15 basics.py
MISSING sentinel __main__
is itself: True | equals None: False
bool: True
copy keeps identity: True True
pickle keeps identity: True
two calls, two objects: False
union: int | MISSING
subclass: type 'sentinel' is not an acceptable base type

Read the output line by line. The repr is the name with no address noise. The sentinel equals only itself. copy, deepcopy and pickle all return the same object, because pickling records “module __main__, name MISSING” and unpickling looks that name up again. Calling sentinel("MISSING") a second time gives a different object. There is no registry, so the name is a label, not a key. The type is final.

Case 2: When None is real data

# cache.py — None is a real value here, so "not found" needs its own marker
NOT_FOUND = sentinel("NOT_FOUND")

_cache: dict[str, str | None] = {"alice": "admin", "bob": None}   # bob: known, no role

def lookup(user: str, default: str | None | NOT_FOUND = NOT_FOUND) -> str | None:
    value = _cache.get(user, NOT_FOUND)
    if value is NOT_FOUND:
        if default is NOT_FOUND:
            raise KeyError(user)
        return default
    return value

print(lookup("alice"), lookup("bob"), lookup("carol", default="guest"))
try:
    lookup("carol")
except KeyError as e:
    print("KeyError:", e)
import inspect
print("signature:", inspect.signature(lookup))
$ python3.15 cache.py
admin None guest
KeyError: 'carol'
signature: (user: str, default: str | None | NOT_FOUND = NOT_FOUND) -> str | None

bob is in the cache with role None. Without a separate marker, lookup("bob") could not tell “bob has no role” apart from “bob is unknown”. The signature line is the readability win. Compare it with the old idiom:

# old.py — the idiom sentinel() replaces
import copy
_missing = object()
def f(x=_missing): ...
print(f.__defaults__, copy.deepcopy(_missing) is _missing)
$ python3.15 old.py
(<object object at 0x7f9258280240>,) False

help() and inspect.signature show the address, and deepcopy produces a new object(). That second problem is the real bug: a config object deep-copied before being passed on suddenly stops matching is _missing.

Case 3: Supporting 3.14 and 3.15 from one module

# compat.py — one definition that works on 3.14 and 3.15+
import sys
if sys.version_info >= (3, 15):
    MISSING = sentinel("MISSING")
else:
    from typing_extensions import Sentinel
    MISSING = Sentinel("MISSING")
print(sys.version_info[:2], repr(MISSING))
$ python3.15 compat.py
(3, 15) MISSING
$ uv run --python 3.14 --with typing_extensions compat.py
(3, 14) MISSING

typing_extensions.Sentinel is the backport. PEP 661 says its behaviour does not precisely match the builtin, so treat it as a bridge, not a twin. Drop the branch when your floor reaches 3.15.

Typing

PEP 661 lets a sentinel stand for its own one-member type, the way None does. Write NAME = sentinel("NAME") with the same name on both sides. Type checkers are asked to flag a mismatch, and to narrow int | MISSING on is / is not. At runtime int | MISSING builds a real typing.Union, as Case 1 printed. Check your checker’s release notes before relying on narrowing in CI, because support follows the PEP rather than shipping with CPython.

The trap

# trap.py — the truthiness trap and the local-sentinel pickle trap
import pickle
UNSET = sentinel("UNSET")

def configure(timeout=UNSET):
    if not timeout:            # wrong: UNSET is truthy, and 0 is a real timeout
        return "default 30s"
    return f"timeout={timeout}"

print(configure(), "|", configure(0))

def make():
    local = sentinel("local")
    return local
try:
    pickle.dumps(make())
except Exception as e:
    print(type(e).__name__ + ":", e)
$ python3.15 trap.py
timeout=UNSET | default 30s
PicklingError: Can't pickle local: it's not found as __main__.local

Both results are wrong, and both arrive silently. UNSET is truthy: PEP 661 deliberately kept the default object behaviour and rejected falsy sentinels. So not timeout treats “not given” as a real timeout, while a real 0 falls back to the default. Second, a sentinel created inside a function cannot be found again by module and name, so pickling it fails. That matters the moment the object crosses a multiprocessing boundary.

The boring rule

Use sentinel() only when None is valid data. Define it once, at module level, with NAME = sentinel("NAME"). Test it only with is / is not. Never create sentinels inside functions that might be pickled. For several related markers with an order (PENDING, DONE, …), use an Enum instead.

Try this

  1. Change _cache.get(user, NOT_FOUND) in Case 2 to _cache.get(user) and find the input that now returns the wrong answer.
  2. Put a sentinel in a class body (class Config: UNSET = sentinel("Config.UNSET")) and check whether it round-trips through pickle. Read PEP 661’s note on __module__ to explain the result.
  3. Send MISSING through multiprocessing.Pool.map and confirm the worker still sees value is MISSING.
  4. Run your type checker over Case 2 with reveal_type(value) inside both branches of if value is NOT_FOUND.

Sources