Python sentinel builtin
last modified October 29, 2026 Python 3.15
In this article we show how to use the sentinel builtin, which
was added in Python 3.15 by PEP 661.
A sentinel is a unique object that marks a special condition, such
as a missing argument, a value that has not been set, or the end of data. The
most common sentinel in Python is None. However, None
is often a valid value itself, and then we need a different marker that cannot
be confused with any real data.
The sentinel(name, /, *, repr=None) callable returns a new unique
object. The object has a readable representation, can be used in type hints,
survives copying and pickling, and compares equal only to itself.
The problem with None
The following example shows a lookup function that uses None to
mean "no default given".
def lookup(data, key, default=None):
if key in data:
return data[key]
if default is None:
raise KeyError(key)
return default
users = {'john': 'admin', 'paul': None}
print(lookup(users, 'john'))
print(lookup(users, 'paul'))
print(lookup(users, 'lucy', 'guest'))
try:
print(lookup(users, 'lucy', None))
except KeyError as e:
print('KeyError:', e)
The function raises a KeyError when the key is missing and no
default is given.
if default is None:
raise KeyError(key)
Because None marks the missing default, the caller cannot pass
None as an actual default value. The last call asks for
None when the user is not found, but it gets an exception.
$ python none_problem.py admin None guest KeyError: 'lucy'
The object() idiom
Before Python 3.15, the usual solution was a private module-level instance of
object.
import inspect
_MISSING = object()
def lookup(data, key, default=_MISSING):
if key in data:
return data[key]
if default is _MISSING:
raise KeyError(key)
return default
users = {'john': 'admin', 'paul': None}
print(lookup(users, 'lucy', None))
print(_MISSING)
print(inspect.signature(lookup))
A fresh object() is not equal to anything else, so it can never
be confused with real data. Now None works as a default value.
print(_MISSING) print(inspect.signature(lookup))
The drawback is the representation. The object prints as
<object object at 0x...>, which says nothing about its
purpose and changes on every run. The same text shows up in function
signatures, in help, and in error messages. The object also
cannot be used in type hints, and it loses its identity when pickled.
$ python object_sentinel.py None <object object at 0x7e623af0c4d0> (data, key, default=<object object at 0x7e623af0c4d0>)
Creating a sentinel
The sentinel builtin fixes these problems. It is available
without an import.
import inspect
MISSING = sentinel('MISSING')
def lookup(data, key, default=MISSING):
if key in data:
return data[key]
if default is MISSING:
raise KeyError(key)
return default
users = {'john': 'admin', 'paul': None}
print(lookup(users, 'paul'))
print(lookup(users, 'lucy', None))
try:
lookup(users, 'lucy')
except KeyError as e:
print('KeyError:', e)
print(MISSING)
print(type(MISSING))
print(inspect.signature(lookup))
By convention, a sentinel is assigned to a variable with the same name as the sentinel.
MISSING = sentinel('MISSING')
The name is used as the representation of the object. We compare with the
sentinel using the is operator.
print(inspect.signature(lookup))
The function signature now reads default=MISSING, which documents
the behaviour much better than a memory address.
$ python basic.py None None KeyError: 'lucy' MISSING <class 'sentinel'> (data, key, default=MISSING)
Sentinel properties
The next example examines how sentinel objects behave.
import copy
MISSING = sentinel('MISSING')
OTHER = sentinel('MISSING')
print(MISSING is OTHER, MISSING == OTHER)
print(MISSING == MISSING, bool(MISSING))
print(copy.copy(MISSING) is MISSING, copy.deepcopy(MISSING) is MISSING)
print(MISSING.__name__, MISSING.__module__)
try:
MISSING.value = 1
except AttributeError as e:
print('AttributeError:', e)
try:
class MySentinel(sentinel):
pass
except TypeError as e:
print('TypeError:', e)
Each call to sentinel creates a new object. Two sentinels with
the same name are different objects, and neither is equal to the other.
print(MISSING == MISSING, bool(MISSING)) print(copy.copy(MISSING) is MISSING, copy.deepcopy(MISSING) is MISSING)
A sentinel is equal to itself and is always truthy. Shallow and deep copies return the same object, so a sentinel stored in a copied data structure keeps its identity.
print(MISSING.__name__, MISSING.__module__)
The sentinel records its name and the module where it was created. The module is used for pickling.
class MySentinel(sentinel):
pass
Sentinels cannot get new attributes, and the sentinel type
cannot be subclassed.
$ python properties.py False False True True True True MISSING __main__ AttributeError: 'sentinel' object has no attribute 'value' and no __dict__ for setting new attributes TypeError: type 'sentinel' is not an acceptable base type
Custom representation
The optional keyword-only repr argument sets a representation
that differs from the name.
NOT_SET = sentinel('NOT_SET')
UNSET = sentinel('UNSET', repr='<unset>')
print(repr(NOT_SET), str(NOT_SET))
print(repr(UNSET), str(UNSET))
print(f'value: {UNSET}')
print([NOT_SET, UNSET])
The custom representation is used by both repr and
str, and therefore also in f-strings and inside containers.
Angle brackets are a common way to make it clear that the value is a marker
and not a string.
$ python custom_repr.py NOT_SET NOT_SET <unset> <unset> value: <unset> [NOT_SET, <unset>]
Sentinels in type hints
A sentinel can be used directly in a type expression together with the
| operator.
import typing
MISSING = sentinel('MISSING')
def next_value(items: list[int], default: int | MISSING = MISSING) -> int:
if items:
return items.pop(0)
if default is MISSING:
raise IndexError('no more values')
return default
hint = typing.get_type_hints(next_value)['default']
print(hint)
print(typing.get_args(hint))
values = [1, 2]
print(next_value(values), next_value(values), next_value(values, 0))
The default parameter accepts an integer or the
MISSING sentinel.
def next_value(items: list[int], default: int | MISSING = MISSING) -> int:
The annotation int | MISSING creates a union at runtime, which
can be inspected with typing.get_type_hints and
typing.get_args. Type checkers that implement PEP 661 narrow the
type after the default is MISSING check, so they know that
default is an int in the last line.
$ python type_hints.py int | MISSING (<class 'int'>, MISSING) 1 2 0
Pickling sentinels
Sentinels can be pickled if they can be found again by name when the data is loaded.
import pickle
MISSING = sentinel('MISSING')
class Config:
DEFAULT = sentinel('Config.DEFAULT')
data = pickle.dumps([MISSING, Config.DEFAULT])
restored = pickle.loads(data)
print(restored)
print(restored[0] is MISSING, restored[1] is Config.DEFAULT)
def make_local():
return sentinel('LOCAL')
try:
pickle.dumps(make_local())
except pickle.PicklingError as e:
print('PicklingError:', e)
The pickle stores only the module and the name of the sentinel. When loading, the name is looked up, so the identity of the sentinel is preserved.
class Config:
DEFAULT = sentinel('Config.DEFAULT')
A sentinel defined in a class must be named with its qualified name, here
Config.DEFAULT.
def make_local():
return sentinel('LOCAL')
A sentinel created inside a function cannot be found by name, so pickling it
fails with a PicklingError.
$ python pickling.py [MISSING, Config.DEFAULT] True True PicklingError: Can't pickle LOCAL: it's not found as __main__.LOCAL
Partial updates
A typical use for a sentinel is a method that updates only the fields the caller passes.
from dataclasses import dataclass
UNCHANGED = sentinel('UNCHANGED')
@dataclass
class Connection:
host: str
timeout: float | None = 30.0
retries: int = 3
def update(self, *, host=UNCHANGED, timeout=UNCHANGED, retries=UNCHANGED):
if host is not UNCHANGED:
self.host = host
if timeout is not UNCHANGED:
self.timeout = timeout
if retries is not UNCHANGED:
self.retries = retries
conn = Connection('example.com')
print(conn)
conn.update(retries=5)
print(conn)
conn.update(timeout=None)
print(conn)
The timeout field accepts None, which means no
timeout. So None cannot mean "leave the field unchanged".
def update(self, *, host=UNCHANGED, timeout=UNCHANGED, retries=UNCHANGED):
if host is not UNCHANGED:
self.host = host
Every parameter defaults to the UNCHANGED sentinel. Only the
fields whose arguments differ from the sentinel are modified, so
update(timeout=None) correctly disables the timeout.
$ python partial_update.py Connection(host='example.com', timeout=30.0, retries=3) Connection(host='example.com', timeout=30.0, retries=5) Connection(host='example.com', timeout=None, retries=5)
Caching missing results
A cache must distinguish between a key that is not cached and a key whose
cached result is None.
MISSING = sentinel('MISSING')
cache = {}
calls = 0
def find_user(user_id):
global calls
calls += 1
return {1: 'John', 2: 'Paul'}.get(user_id)
def cached_find_user(user_id):
result = cache.get(user_id, MISSING)
if result is MISSING:
result = cache[user_id] = find_user(user_id)
return result
for user_id in [1, 3, 1, 3, 2]:
print(user_id, cached_find_user(user_id))
print(f'{calls} lookups for 5 calls')
The find_user function returns None for unknown
users. That result is worth caching too.
result = cache.get(user_id, MISSING)
if result is MISSING:
result = cache[user_id] = find_user(user_id)
The get method returns the sentinel only when the key is absent.
A cached None is returned as is, so the unknown user 3 is looked
up only once.
$ python cache.py 1 John 3 None 1 John 3 None 2 Paul 3 lookups for 5 calls
Comparing with alternatives
Besides object(), programmers have used enumerations and the
Ellipsis object as sentinels.
from enum import Enum
OBJECT = object()
class Marker(Enum):
MISSING = 'MISSING'
SENTINEL = sentinel('MISSING')
for value in (OBJECT, Marker.MISSING, Ellipsis, SENTINEL):
print(f'{type(value).__name__:10} {value!r}')
An enumeration member has a readable representation and can be pickled and
used in type hints, but it needs a whole class for one value. The
Ellipsis object is a shared singleton, so it may also be a valid
value in some programs, for example in slicing code. The sentinel
builtin combines a short definition with a clear representation.
$ python alternatives.py object <object object at 0x76e9c1f10220> Marker <Marker.MISSING: 'MISSING'> ellipsis Ellipsis sentinel MISSING
Source
In this article we have worked with the sentinel builtin of
Python 3.15. See also Python None keyword
and Python ellipses.
Author
List all Python tutorials.