CPython's Sentinel Implementation in C: Unique Marker Objects for Defaults

PEP 661's sentinel builtin is CPython's answer to MISSING and UNSET. Here's how it's implemented in C: the sentinelobject struct, caller-based module detection, GC tracking, pickling rules, and union support.

TL;DR: Python 3.15 adds a sentinel builtin (PEP 661). Every call to sentinel("MISSING") allocates a new, distinct object — not a process-wide singleton — carrying a name, an auto-detected module, and an optional repr, all in Objects/sentinelobject.c.

None, Ellipsis, and NotImplemented are singletons: one object per process. But sometimes you want a marker value that’s unique to your library — MISSING, UNSET, NOT_PROVIDED.

Python 3.15’s sentinel builtin gives you exactly that. Unlike the classic singletons, these are not process-wide: every call allocates a fresh, distinct object. Each one carries a name, a module, and an optional repr, and it’s tracked by the garbage collector. The whole implementation lives in Objects/sentinelobject.c. Let’s trace it through the 3.15 source.

The struct: sentinelobject

A sentinel is a small GC object with three owned references:

c
// Objects/sentinelobject.c
typedef struct {
    PyObject_HEAD
    PyObject *name;
    PyObject *module;
    PyObject *repr;
} sentinelobject;
  • name is the __name__ (a str, or a str subclass).
  • module is the __module__, usually inferred from the caller.
  • repr is an optional custom repr string. When it’s NULL, repr() falls back to name.

The cast macro is exact, because the type doesn’t allow subclasses:

c
// Objects/sentinelobject.c
#define sentinelobject_CAST(op) \
    (assert(PySentinel_Check(op)), _Py_CAST(sentinelobject *, (op)))

The type: PySentinel_Type

PySentinel_Type is a GC, immutable type. It’s not a base type, so subclassing is rejected:

c
// Objects/sentinelobject.c
PyTypeObject PySentinel_Type = {
    PyVarObject_HEAD_INIT(&PyType_Type, 0)
    .tp_name = "sentinel",
    .tp_basicsize = sizeof(sentinelobject),
    .tp_dealloc = sentinel_dealloc,
    .tp_repr = sentinel_repr,
    .tp_as_number = &sentinel_as_number,
    .tp_hash = PyObject_GenericHash,
    .tp_getattro = PyObject_GenericGetAttr,
    .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_IMMUTABLETYPE
                | Py_TPFLAGS_HAVE_GC,
    .tp_doc = sentinel_doc,
    .tp_traverse = sentinel_traverse,
    .tp_clear = sentinel_clear,
    .tp_richcompare = _Py_BaseObject_RichCompare,
    .tp_methods = sentinel_methods,
    .tp_members = sentinel_members,
    .tp_new = sentinel_new,
    .tp_free = PyObject_GC_Del,
};

The flags and slots worth calling out:

  • Py_TPFLAGS_HAVE_GC is set because name, module, and repr can participate in reference cycles — for example, a str subclass that stores the sentinel.
  • Py_TPFLAGS_IMMUTABLETYPE makes the type object itself immutable.
  • Py_TPFLAGS_BASETYPE is not set, so class Sub(sentinel) raises TypeError.
  • tp_richcompare is _Py_BaseObject_RichCompare, so equality is identity: a sentinel equals itself and nothing else.
  • tp_hash is PyObject_GenericHash, i.e. identity-based hashing.
  • tp_as_number.nb_or is _Py_union_type_or, which is what makes sentinel | int (and int | sentinel) work in type expressions.

The type is registered in the interpreter’s type table (Objects/object.c) and exposed as a builtin:

c
// Python/bltinmodule.c
SETBUILTIN("sentinel", &PySentinel_Type);

The header exposes the check macros, which are exact checks because subclasses are impossible:

c
// Include/cpython/sentinelobject.h
#define PySentinel_CheckExact(op) Py_IS_TYPE((op), &PySentinel_Type)
#define PySentinel_Check(op) PySentinel_CheckExact(op)

Construction: sentinel_new

The clinic-generated sentinel_new accepts a positional-only name (which must be a str or subclass) and a keyword-only repr (default None):

c
// Objects/sentinelobject.c
static PyObject *
sentinel_new_impl(PyTypeObject *type, PyObject *name, PyObject *repr)
{
    if (repr == Py_None) {
        repr = NULL;
    }
    else if (!PyUnicode_Check(repr)) {
        _PyArg_BadArgument("sentinel", "argument 'repr'", "str or None", repr);
        return NULL;
    }
    PyObject *module = caller();
    PyObject *self = sentinel_new_with_module(type, name, module, repr);
    Py_DECREF(module);
    return self;
}

The module isn’t passed by the user. Instead, caller() inspects the current execution frame and returns the module of the function that’s currently running:

c
// Objects/sentinelobject.c
static PyObject *
caller(void)
{
    _PyInterpreterFrame *f = _PyThreadState_GET()->current_frame;
    if (f == NULL || PyStackRef_IsNull(f->f_funcobj)) {
        assert(!PyErr_Occurred());
        Py_RETURN_NONE;
    }
    PyFunctionObject *func = _PyFrame_GetFunction(f);
    assert(PyFunction_Check(func));
    PyObject *r = PyFunction_GetModule((PyObject *)func);
    if (!r) {
        assert(!PyErr_Occurred());
        Py_RETURN_NONE;
    }
    return Py_NewRef(r);
}

This is why a module-level MISSING = sentinel("MISSING") automatically gets __module__ set to that module’s name — and that’s exactly what makes the value picklable later. At top level with no current function, caller() returns None.

Allocation itself is ordinary GC allocation, followed by tracking:

c
// Objects/sentinelobject.c
static PyObject *
sentinel_new_with_module(PyTypeObject *type, PyObject *name, PyObject *module, PyObject *repr)
{
    assert(PyUnicode_Check(name));

    sentinelobject *self = PyObject_GC_New(sentinelobject, type);
    if (self == NULL) {
        return NULL;
    }
    self->name = Py_NewRef(name);
    self->module = Py_NewRef(module);
    self->repr = Py_XNewRef(repr);
    _PyObject_GC_TRACK(self);
    return (PyObject *)self;
}

Note that module is always stored (even when it’s None), while repr is stored only when provided.

The C API: PySentinel_New

For C extensions, PySentinel_New builds a sentinel without going through the Python-level caller machinery. The caller supplies the module name (or NULL for None) and the repr (or NULL for “use the name”):

c
// Objects/sentinelobject.c
PyObject *
PySentinel_New(const char *name, const char *module_name, const char *repr)
{
    PyObject *name_obj = PyUnicode_FromString(name);
    if (name_obj == NULL) {
        return NULL;
    }
    PyObject *repr_obj = NULL;
    if (repr != NULL) {
        repr_obj = PyUnicode_FromString(repr);
        if (repr_obj == NULL) {
            Py_DECREF(name_obj);
            return NULL;
        }
    }
    PyObject *module_obj = module_name == NULL
        ? Py_None
        : PyUnicode_FromString(module_name);
    if (module_obj == NULL) {
        Py_DECREF(name_obj);
        Py_XDECREF(repr_obj);
        return NULL;
    }

    PyObject *sentinel = sentinel_new_with_module(
        &PySentinel_Type, name_obj, module_obj, repr_obj);
    Py_DECREF(module_obj);
    Py_DECREF(name_obj);
    Py_XDECREF(repr_obj);
    return sentinel;
}

The returned object is a new reference owned by the caller.

Garbage collection lifecycle

Because a sentinel owns references, it participates in the cyclic GC:

c
// Objects/sentinelobject.c
static int
sentinel_traverse(PyObject *op, visitproc visit, void *arg)
{
    sentinelobject *self = sentinelobject_CAST(op);
    Py_VISIT(self->name);
    Py_VISIT(self->module);
    Py_VISIT(self->repr);
    return 0;
}

static int
sentinel_clear(PyObject *op)
{
    sentinelobject *self = sentinelobject_CAST(op);
    Py_CLEAR(self->name);
    Py_CLEAR(self->module);
    Py_CLEAR(self->repr);
    return 0;
}

static void
sentinel_dealloc(PyObject *op)
{
    _PyObject_GC_UNTRACK(op);
    (void)sentinel_clear(op);
    Py_TYPE(op)->tp_free(op);
}

tp_free is PyObject_GC_Del, matching the PyObject_GC_New allocation. The traverse/clear pair is what lets the test suite create a str subclass that points back at the sentinel and still collect both objects once they become unreachable.

Attributes: __name__ and __module__

The two attributes are exposed as members rather than computed properties:

c
// Objects/sentinelobject.c
static PyMemberDef sentinel_members[] = {
    {"__name__", Py_T_OBJECT_EX, offsetof(sentinelobject, name), Py_READONLY},
    {"__module__", Py_T_OBJECT_EX, offsetof(sentinelobject, module), 0},
    {NULL}
};
  • __name__ is read-only: assigning or deleting it raises AttributeError.
  • __module__ is read/write and deletable. Deleting it sets the underlying pointer to NULL; a subsequent read raises AttributeError rather than returning None.
  • The type itself (sentinel) rejects attribute assignment entirely, because it’s immutable (Py_TPFLAGS_IMMUTABLETYPE and no tp_setattro override that allows it). Hence sentinel.attribute = ... raises TypeError.

tp_getattro is PyObject_GenericGetAttr, so normal attribute lookup (including the methods below) works.

Repr

c
// Objects/sentinelobject.c
static PyObject *
sentinel_repr(PyObject *op)
{
    sentinelobject *self = sentinelobject_CAST(op);
    if (self->repr != NULL) {
        return Py_NewRef(self->repr);
    }
    return Py_NewRef(self->name);
}

tp_str is 0, so str() falls back to repr(). A custom repr="custom" therefore affects both repr() and str().

Copy, deepcopy, and equality

Sentinels are identity objects: copying returns the same instance, and equality is identity:

c
// Objects/sentinelobject.c
static PyObject *
sentinel_copy(PyObject *self, PyObject *Py_UNUSED(ignored))
{
    return Py_NewRef(self);
}

static PyObject *
sentinel_deepcopy(PyObject *self, PyObject *Py_UNUSED(memo))
{
    return Py_NewRef(self);
}
c
// Objects/sentinelobject.c (methods table)
static PyMethodDef sentinel_methods[] = {
    {"__copy__", sentinel_copy, METH_NOARGS, NULL},
    {"__deepcopy__", sentinel_deepcopy, METH_O, NULL},
    {"__reduce__", sentinel_reduce, METH_NOARGS, NULL},
    {NULL, NULL}
};

Because tp_richcompare is _Py_BaseObject_RichCompare, s == s is True and s == other is False. And copy.copy / copy.deepcopy hand back the same object (assertIs(copy.copy(s), s)).

Pickling: __reduce__

c
// Objects/sentinelobject.c
static PyObject *
sentinel_reduce(PyObject *op, PyObject *Py_UNUSED(ignored))
{
    sentinelobject *self = sentinelobject_CAST(op);
    return Py_NewRef(self->name);
}

__reduce__ returns the name string. Pickle interprets a string result as a global name lookup: it imports __module__ and fetches the attribute named name.

So a sentinel pickles successfully only when it’s reachable as an attribute of an importable module under exactly that name. A dynamically created sentinel("MISSING") that isn’t stored anywhere is not picklable and raises PicklingError. That matches the C API documentation: for pickling to work, module_name must name an importable module and the sentinel must be accessible from it.

Gotcha: this is the whole reason the module-level pattern matters. Define your sentinel at module scope, not inside a function.

Union types: sentinel | X

sentinel overrides nb_or so it can appear in X | Y type expressions:

c
// Objects/sentinelobject.c
static PyNumberMethods sentinel_as_number = {
    .nb_or = _Py_union_type_or,
};

_Py_union_type_or (in Objects/unionobject.c) only proceeds when both operands are “unionable”, and a sentinel is explicitly in that set:

c
// Objects/unionobject.c
static int
is_unionable(PyObject *obj)
{
    if (obj == Py_None ||
        PyType_Check(obj) ||
        PySentinel_Check(obj) ||
        _PyGenericAlias_Check(obj) ||
        _PyUnion_Check(obj) ||
        Py_IS_TYPE(obj, &_PyTypeAlias_Type)) {
        return 1;
    }
    return 0;
}

So missing | int, int | missing, missing | None, and missing | list[int] all build a types.UnionType. A union of a sentinel with itself collapses to the sentinel itself (missing | missing is missing), because the union builder deduplicates equal — here identical — members.

Memory layout

A sentinel is a normal heap-allocated GC object:

text
sentinelobject (heap, GC-tracked)
+----------------------------+
| ob_refcnt                  |
+----------------------------+
| ob_flags   (tracked bits)  |
+----------------------------+
| *ob_type  (&PySentinel_Type)|
+----------------------------+
| *name     (owned str)      |
+----------------------------+
| *module   (owned or NULL)  |
+----------------------------+
| *repr     (owned or NULL)  |
+----------------------------+

There’s no freelist and no static instance: every call allocates. The object is collected by the cyclic GC once it becomes unreachable, and its three owned references are cleared in sentinel_clear.

Key takeaways

  • sentinel is a builtin, GC-tracked, immutable, non-subclassable type whose instances are heap-allocated in Objects/sentinelobject.c.
  • Each instance stores name (read-only __name__), module (mutable, deletable __module__), and an optional repr.
  • The module is inferred from the calling function’s frame by caller(); the C API PySentinel_New takes it explicitly.
  • Equality and hashing are identity-based; __copy__/__deepcopy__ return self.
  • __reduce__ returns name, so pickling works only for module-level sentinels reachable by that name.
  • nb_or delegates to _Py_union_type_or, and sentinels are explicitly unionable, enabling sentinel | X in type expressions.
  • The type and its instances are tracked by the GC, so reference cycles involving the stored strings get collected.

Further reading

  • Objects/sentinelobject.c – sentinelobject, PySentinel_Type, sentinel_new, sentinel_new_with_module, caller, PySentinel_New, sentinel_repr, sentinel_copy, sentinel_deepcopy, sentinel_reduce, sentinel_traverse, sentinel_clear, sentinel_dealloc.
  • Include/cpython/sentinelobject.h – PySentinel_Type, PySentinel_Check, PySentinel_CheckExact, PySentinel_New.
  • Objects/unionobject.c – is_unionable, _Py_union_type_or.
  • Python/bltinmodule.c – registration of sentinel as a builtin.
  • Lib/test/test_builtin.py – test_sentinel, test_sentinel_attributes, test_sentinel_repr, test_sentinel_pickle, test_sentinel_str_subclass_name_cycle, test_sentinel_union.
  • Doc/c-api/sentinel.rst and Doc/whatsnew/3.15.rst – public docs and PEP 661.

PythonCpythonInternalsSentinel