ZetCode

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".

none_problem.py
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.

object_sentinel.py
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.

basic.py
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.

properties.py
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.

custom_repr.py
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.

type_hints.py
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.

pickling.py
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.

partial_update.py
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.

cache.py
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.

alternatives.py
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

Built-in functions – sentinel

PEP 661 – Sentinel Values

In this article we have worked with the sentinel builtin of Python 3.15. See also Python None keyword and Python ellipses.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Python tutorials.