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
sentinelbuiltin (PEP 661). Every call tosentinel("MISSING")allocates a new, distinct object — not a process-wide singleton — carrying a name, an auto-detected module, and an optional repr, all inObjects/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:
// Objects/sentinelobject.c
typedef struct {
PyObject_HEAD
PyObject *name;
PyObject *module;
PyObject *repr;
} sentinelobject;nameis the__name__(astr, or astrsubclass).moduleis the__module__, usually inferred from the caller.repris an optional custom repr string. When it’sNULL,repr()falls back toname.
The cast macro is exact, because the type doesn’t allow subclasses:
// 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:
// 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_GCis set becausename,module, andreprcan participate in reference cycles — for example, astrsubclass that stores the sentinel.Py_TPFLAGS_IMMUTABLETYPEmakes the type object itself immutable.Py_TPFLAGS_BASETYPEis not set, soclass Sub(sentinel)raisesTypeError.tp_richcompareis_Py_BaseObject_RichCompare, so equality is identity: a sentinel equals itself and nothing else.tp_hashisPyObject_GenericHash, i.e. identity-based hashing.tp_as_number.nb_oris_Py_union_type_or, which is what makessentinel | int(andint | sentinel) work in type expressions.
The type is registered in the interpreter’s type table (Objects/object.c) and exposed as a builtin:
// Python/bltinmodule.c
SETBUILTIN("sentinel", &PySentinel_Type);The header exposes the check macros, which are exact checks because subclasses are impossible:
// 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):
// 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:
// 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:
// 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”):
// 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:
// 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:
// 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 raisesAttributeError.__module__is read/write and deletable. Deleting it sets the underlying pointer toNULL; a subsequent read raisesAttributeErrorrather than returningNone.- The type itself (
sentinel) rejects attribute assignment entirely, because it’s immutable (Py_TPFLAGS_IMMUTABLETYPEand notp_setattrooverride that allows it). Hencesentinel.attribute = ...raisesTypeError.
tp_getattro is PyObject_GenericGetAttr, so normal attribute lookup (including the methods below) works.
Repr
// 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:
// 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);
}// 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__
// 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:
// 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:
// 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:
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
sentinelis a builtin, GC-tracked, immutable, non-subclassable type whose instances are heap-allocated inObjects/sentinelobject.c.- Each instance stores
name(read-only__name__),module(mutable, deletable__module__), and an optionalrepr. - The module is inferred from the calling function’s frame by
caller(); the C APIPySentinel_Newtakes it explicitly. - Equality and hashing are identity-based;
__copy__/__deepcopy__returnself. __reduce__returnsname, so pickling works only for module-level sentinels reachable by that name.nb_ordelegates to_Py_union_type_or, and sentinels are explicitly unionable, enablingsentinel | Xin 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 ofsentinelas 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.rstandDoc/whatsnew/3.15.rst– public docs and PEP 661.