skip to content

Why do Python libraries use a module-level sentinel object with `is` checks?

level: seniorimportance: should knowfreq 35%

answer

  1. None is sometimes a real answer
  2. One marker, created once, module level
  3. The operator nobody can override
  4. Give it a readable repr
  5. It does not survive pickling

basics

~20 s

To tell an omitted argument from one explicitly passed as None, when None is itself a valid value. A unique module-level marker is compared with is, which no class can override, cannot raise, and costs nothing.

solid answer

~50 s

A default of `None` cannot distinguish "not supplied" from "supplied as `None`" whenever `None` is a meaningful input: an unset option versus an explicitly cleared one, or a cache miss versus a cached `None`. The fix is one marker object created at module level and used as the default, tested with `is`. Identity is the right operator for three reasons: a fresh `object()` is equal to nothing but itself, `is` cannot be overridden by whatever the caller passed, and it runs no user code, so it cannot raise or be slow. In production, give the marker a class with a readable `__repr__` so it prints as `MISSING` in signatures and tracebacks; the standard library does exactly that with `dataclasses.MISSING`. The trap is serialisation: copying or pickling a sentinel produces a new object, so `is` fails across a process or cache boundary — use an `enum` member there.

code

python · 21 lines
python
import copy
import pickle

_MISSING = object()
HOUSE_DEFAULT = 2


def render_total(amount, rounding=_MISSING):
    if rounding is _MISSING:
        rounding = HOUSE_DEFAULT       # not supplied
    if rounding is None:
        return amount                  # explicitly: do not round
    return round(amount, rounding)


print(render_total(12.3456))           # house default
print(render_total(12.3456, None))     # raw figure
print(render_total(12.3456, 1))

print(copy.deepcopy(_MISSING) is _MISSING)               # False
print(pickle.loads(pickle.dumps(_MISSING)) is _MISSING)  # False

go deeper

for a junior

Know the shape of the idiom and why it exists: a module-level marker used as a default so the function can tell an omitted argument from one passed as None. Testing it with is, not ==, is the part to remember.

for a middle

Explain all three reasons identity is the right operator — a fresh object equals nothing else, is cannot be overridden, and no user code runs — and contrast the sentinel with a plain None default and with a mutable default.

for a senior

Show production judgement: a readable repr for tracebacks and signatures, the standard library precedent, and the identity-does-not-survive-pickling failure that turns up when arguments cross a worker process, a cache or a deep copy.

for a principal

Decide the convention: one shared sentinel style across the codebase, when a three-state enum or a split API is the honest design instead, and how the choice interacts with type checking and with anything that serialises call arguments.

The pattern is a module-level marker object, created once, compared only with `is`: ```python _MISSING = object() def render(invoice, rounding=_MISSING): if rounding is _MISSING: rounding = HOUSE_DEFAULT ... ``` It exists to answer a question a default value cannot: **was this argument supplied at all?** **Why `None` is not always enough.** `None` is the usual "nothing here" default, and when `None` is not a meaningful value for the parameter it is the right one — do not reach for a sentinel by reflex. The sentinel earns its place when `None` is itself a legitimate, distinguishable input. In an invoice-PDF renderer, `rounding=None` can reasonably mean "do not round at all, print the raw figure", while *omitting* the argument means "use the house default of two decimal places". Collapse those two cases into one and you get the classic slow failure: a caller who deliberately asked for no rounding silently gets the default, the totals drift by fractions of a cent against the ledger, and because the reports only reconcile at month end, the defect ships and rides an entire three-week release train before anyone traces it back to the argument that was never distinguishable in the first place. The other places the same need appears are caches (a stored `None` result is a real cached value, not a miss), configuration layering (unset versus explicitly cleared), and partial update payloads (a field absent versus a field set to null). **Why `is` and not `==`.** Three reasons, and an interviewer usually wants at least two. First, identity is exact: a bare `object()` instance is equal to nothing but itself, and `is` cannot be overridden — no dunder participates, so no caller can spoof it. Second, `==` runs arbitrary user code: the argument may be an object whose `__eq__` returns `True` for everything, or one that raises, or an array-like value whose `__eq__` returns a non-boolean and blows up in the `if`. Third, identity is a pointer comparison, so the guard costs nothing on a hot path. The corresponding anti-pattern is a mutable default, `def f(x=[])`, used as a "was it supplied" marker: it is shared across calls and mutable by the callee. **Making the sentinel behave in production.** A bare `object()` has an unhelpful repr, so it shows up in tracebacks and help output as `<object object at 0x…>`. Give the marker a small class with a `__repr__` and instantiate it once, so signatures and error messages read as `MISSING`. The standard library does exactly this — `dataclasses.MISSING` is a single instance of a private marker class used to mean "no default was declared". **The failure mode worth knowing: identity does not survive serialisation.** Copying or pickling a sentinel produces a *new* object, so the receiving side's `is` check fails: `copy.deepcopy(_MISSING) is _MISSING` is `False`, and so is a pickle round trip. That matters whenever a call's arguments cross a boundary — a task queued to a worker process, a cached call re-hydrated from disk, a deep-copied configuration tree. Under `multiprocessing`, whose default start method on 3.14 is `forkserver` on Unix other than macOS and `spawn` on macOS and Windows, the child re-imports your module and creates *its own* sentinel object; anything you send to it is pickled. A sentinel that must survive that should be an `enum` member — enum members pickle by name and resolve back to the one canonical instance, so `is` still holds on the other side — or a class implementing `__reduce__` to return its own name. Design decision, not trivia: sentinels are for in-process control flow unless you make them serialisation-aware. **Typing.** A sentinel default is awkward to annotate: the parameter's declared type does not include the marker's type, so type checkers need either a private union, an overload set that distinguishes the supplied and omitted calls, or the marker typed as its own singleton class. There is no accepted language-level sentinel type, so pick one convention and use it everywhere in a codebase rather than inventing a new marker per module. **Alternatives worth naming.** Accepting `**kwargs` and testing key presence avoids the marker entirely, at the cost of a signature nobody can read and no editor can complete. Splitting the API into two functions — one that takes the value, one that does not — is often the cleanest answer for a small surface. And where the parameter is genuinely a three-state flag, an explicit enum with three members says what a sentinel only implies.

  • Why not compare the sentinel with `==` instead of `is`?
    Because `==` runs the operands' code. The caller may pass an object whose `__eq__` returns `True` for everything, one that raises, or one that returns a non-boolean and breaks the `if`. Identity cannot be overridden, cannot raise, and is a pointer comparison, so the guard is exact and free. `==` on a marker is a defect even when it happens to work.
  • When is a plain `None` default the right choice after all?
    Whenever `None` is not a meaningful value for that parameter — which is most of the time. A sentinel adds a private name, an awkward annotation and a repr to maintain, so it should appear only where the two states genuinely differ: unset versus cleared, miss versus cached-`None`, absent versus null. Reaching for one by reflex is over-engineering.
  • What breaks when a sentinel default crosses a process boundary?
    Identity. Arguments sent to a worker process are pickled, and unpickling a bare `object()` yields a new instance, so the receiving side's `is` check is false and it takes the supplied branch. Make the marker an `enum` member, which pickles by name back to the one canonical instance, or implement `__reduce__` to return the marker's name.
  • How do you annotate a parameter whose default is a sentinel?
    The declared type does not include the marker's type, so a checker needs help: give the marker its own singleton class and annotate the parameter as the union of the real type and that class, or declare overloads for the supplied and omitted call shapes. Pick one convention for the codebase instead of inventing a new marker per module.

A blank form field and a field with a line struck through it mean different things; the sentinel is the difference between nobody having written anything and somebody having written nothing on purpose.

saying these in an interview costs you the question

  • Uses None as the marker where None is a valid value
  • Compares the sentinel with == instead of is
  • Uses a mutable default such as [] as the marker
  • Creates a fresh object() inside the function body
  • Assumes a sentinel survives pickling or deep copying
  • Thinks two distinct object() instances can compare equal

context