Python 3.15 Ships a Built-In sentinel After Five Years of PEP 661
Software / explainer
Python 3.15 Ships a Built-In sentinel After Five Years of PEP 661
The new type replaces the object() idiom with a one-line constructor that survives copying, pickling and type checking, but a Python 3.15 floor is the price of using it.
Python 3.15, released on Oct. 9, 2026, adds a sentinel type to the builtins module, the end of a proposal that Tal Einat opened in June 2021. It gives every project a standard way to say "no value was passed" that is different from None.
The change is listed in the What's New in Python 3.15 notes as PEP 661 and was contributed by Jelle Zijlstra in gh-148829. The PEP itself lists Einat and Zijlstra as authors, gives its resolution date as April 23, 2026, and now marks it Final and historical, with the built-in docs as the canonical reference.
The task a sentinel exists for
Take a function that updates a user record. A caller who passes email=None might mean "delete the address", or might mean "leave it alone". Both are reasonable readings, and None cannot carry both.
Florian Wilhelm, in a post dated Oct. 22, 2026, calls None "probably the most overloaded value in Python" and walks through the same trap in a cache (a missing key versus a key that maps to None) and a config update where timeout=None could mean no timeout or keep the current one.
The usual fix has been a private marker. The PEP's motivation section says the common object() version has an overly verbose repr, lacks a distinct type for type signatures, and can break identity after copying or pickling.
What the 3.15 API does
The signature, per the Python 3.15.2 built-in functions reference, is sentinel(name, /, *, repr=None). The name must be a str and becomes the default repr.
MISSING = sentinel("MISSING")
def update_email(user_id: int, email: str | None | MISSING = MISSING):
if email is MISSING:
return # caller said nothing: leave the record alone
if email is None:
... # caller asked to delete the address
else:
... # store email.lower()
The docs say sentinels are truthy, compare equal only to themselves and are meant to be compared with is. They cannot be subclassed, and shallow and deep copies return the same object.
| Behaviour | object() idiom | sentinel() in 3.15 |
|---|---|---|
| Repr | <object object at 0x...> style | The name, or the repr argument |
| Copy and deepcopy | Identity can break (PEP 661) | Returns the same object |
| Type hints | No distinct type | Works with | in type expressions |
| Pickling | Identity can break (PEP 661) | Preserved if importable by name |
Pickling has a catch. Only sentinels placed in a module's global scope under a matching name, or in a class scope under the qualified name, can be pickled. A sentinel created inside a function cannot. Class-scope sentinels need the qualified name, as in sentinel("Cls.PICKLABLE").
Why PEP 661 rejected the alternatives
The PEP's rejected-alternatives list is the part worth reading before you reach for a library. A single shared MISSING was refused because it could collide with a valid value and cannot have a context-specific name. A single-valued Enum was refused as repetitive with a long repr, and it received no votes in the poll the PEP describes.
The Steering Council asked for a builtin rather than a new stdlib module, according to the PEP. The PEP adds that the name sentinels conflicts with an existing PyPI package.
Literal["MISSING"] was rejected too, because it would refer to a string and cause confusion.
What still depends on your type checker
The PEP says type checkers should recognise NAME = sentinel('NAME'), report an error when the two names differ, and narrow unions with is and is not. Wilhelm's post says checkers can then narrow a sentinel "just like None", and adds the caveat in his own words: "check your type checker before relying on it."
This article did not run mypy, pyright or any other checker against the example above, so the narrowing behaviour is the PEP's specification and Wilhelm's account, not a test result.
The PEP also defers customising truthiness. Sentinels are always truthy, unlike None, so a stray if not email: check treats a sentinel as a present value.
For other language and library changes covered on this site, see the report on Swift 6.4 and WebAssembly and the one on Firezone's Rust WireGuard rewrite.
Libraries that must still run on older interpreters have one route in Wilhelm's post: the typing_extensions backport. He also notes that Pydantic's MISSING, which separates an explicit null from a field never sent, left its experimental stage in Pydantic 2.14.
The docs page for 3.15.2 is the first place to check the pickling rules before moving an existing marker over, since __module__ is writable and the PEP says that exists to ease migrating standard-library sentinels.
Sources
More in Software
- 01Lightpanda 1.0 Passes 80% of Chrome's Web Tests, by Its Own CountThe Zig-built headless browser left beta on Oct. 2, and its own benchmarks put it ahead of two Rust rivals on a 940-page crawl, with no hardware listed.
- 02Claude-Mem Hits 107,900 Stars, and Its Own Pages Disagree on LicenceThe Claude Code memory plugin has gained about 35,500 stars since May, but a May write-up says AGPL-3.0, the repository now says Apache-2.0, and the README describes an optional cloud sync that earlier coverage did not.
- 03Jepsen Found 96 Issues in ParticleDB 1.1.4 and Tells Users to Halt DeploymentKyle Kingsbury’s 31 October report, funded by vendor Orkes, finds lost writes and ignored constraints in healthy single-node runs.
- 04kube32 Runs Kubernetes Pods on ESP32-S3 Boards, and Its README Lists TLS Its Own Quick Start SkipsThe Apache 2.0 project, at 25 stars, runs WebAssembly pods under kubectl, but its SECURITY.md says the API serves plain HTTP with authentication off by default.