How to Use the New sentinel() Builtin in Python 3.15 to Tell a Missing Argument From None
Python 3.15 adds a sentinel() builtin for unique placeholder values. This tutorial uses it to tell an omitted argument from None and tests copy, pickle, multiprocessing, mypy, Ruff, and older Python...
A function with an optional argument has to answer one small question: did the caller give me a value, or not? Python’s usual answer is a default of None. That works until None is also a legitimate value, such as “clear this field” or “this user does not exist”. At that point the code needs a different marker, one that no caller can pass by accident.
Table Of Content
- What a sentinel value is
- Prerequisites
- Check that your Python has the builtin
- Step 1: See why None is not enough
- What this does and why it matters
- Check that you saw the bug
- Step 2: Try the old object() fix and see what it costs
- What worked
- Cost 1: an unreadable signature
- Cost 2: copies stop recognizing the marker
- Cost 3: pickling does the same
- Step 3: Send the marker to another process
- What happened
- Check that you saw the hang
- Step 4: Meet the sentinel() builtin
- What each line shows
- Check it worked
- Step 5: Learn the rules of sentinel()
- Creating a sentinel
- What you cannot do with one
- When pickling works
- Check it worked
- Step 6: Rewrite the partial update with MISSING
- What this does and why it matters
- The truthiness trap
- Check it worked
- Step 7: Make a cache remember “not found”
- Why the numbers differ
- Check it worked
- Step 8: Stop the worker process with a sentinel
- Why this works
- Check it worked
- Step 9: Let the type checker help
- Ask mypy what it infers
- Check it worked
- Step 10: Support Python versions before 3.15
- What to notice
- Step 11: Check your code base for names that collide
- What this means for you
- Step 12: Lock the behavior in with tests
- What the tests protect
- Check it worked
- Confirm everything works end to end
- Common mistakes
- Using truthiness or == instead of is
- Creating a sentinel inside a function
- Letting a sentinel escape
- Forgetting that None is still a value
- Where to go next
Python 3.15 adds that marker as a builtin called sentinel, specified in PEP 661. In this tutorial you will start from a real bug caused by None, try the old workaround (a bare object()) and watch it fail in three ways, and then replace it with sentinel() in four small pieces of code: a partial-update function, a cache that remembers a miss, a stop signal for a worker process, and a type-checked signature. You will also learn how to support Python versions before 3.15, how a linter reacts to the new builtin name, and how to lock the behavior in with tests.
What a sentinel value is
A sentinel value is a unique placeholder object that exists only so that code can recognize it. You check for it with the is operator, which asks “is this the very same object?”, instead of ==, which asks “are these two objects equal?”. None is the sentinel Python programmers reach for first. PEP 661 explains why it is sometimes not enough: “sometimes an alternative sentinel value is needed, usually when it needs to be distinct from None since None is a valid value in that context.”
Three states are in play in the code you will write here. A value can be missing (nobody supplied anything), empty on purpose (somebody supplied None), or present (somebody supplied a real value). A default of None can express only two of those three. A sentinel gives the third state its own name.
Prerequisites
You need Python 3.15.0 or later, a terminal, a text editor, and an empty folder to hold the files. You should be comfortable with functions, keyword arguments with defaults, and dictionaries. No prior knowledge of sentinels, pickle, or multiprocessing is assumed, because each is explained where it first appears. Steps 9, 11, and 12 use three optional tools that you can install with python -m pip install pytest mypy ruff. Step 10 uses the typing-extensions package on an older Python.
I ran the scripts and tests on Windows 11 with the python.org 3.15.0 embeddable build (its build date is October 9, 2026) and pytest 9.1.1. I ran mypy 2.4.0 and Ruff 0.17.1 from a separate Python 3.13.14 environment with their target version set explicitly, and I used that same Python 3.13.14 with typing-extensions 4.16.0 as the older interpreter in Step 10. The commands should work the same way on Linux and macOS if you type python3.15 where this article says python. The one part that depends on the platform is multiprocessing in Steps 3 and 8, and I point out why there.
Check that your Python has the builtin
Open a terminal in your empty folder and run:
python --version
python -c "print(sentinel)"
Expected output:
Python 3.15.0
<class 'sentinel'>
The second command prints the builtin class itself, which proves the name exists without any import. If you see NameError: name 'sentinel' is not defined instead, your python is older than 3.15. Read Step 10 before you continue, because it shows how to get the same behavior on older versions.
Step 1: See why None is not enough
Imagine a small function that edits a user profile. Callers pass only the fields they want to change, and the function returns an updated copy of the profile. Create step01_none_default.py:
# step01_none_default.py
def update_profile(profile, *, name=None, email=None, phone=None):
"""Return a copy of profile with the given fields changed. None means "leave it alone"."""
changed = dict(profile)
if name is not None:
changed["name"] = name
if email is not None:
changed["email"] = email
if phone is not None:
changed["phone"] = phone
return changed
profile = {"name": "Ada Lovelace", "email": "[email protected]", "phone": "555-0100"}
print("start: ", profile)
print("rename: ", update_profile(profile, name="Ada King"))
print("clear phone: ", update_profile(profile, phone=None))
Run it with python step01_none_default.py. Expected output:
start: {'name': 'Ada Lovelace', 'email': '[email protected]', 'phone': '555-0100'}
rename: {'name': 'Ada King', 'email': '[email protected]', 'phone': '555-0100'}
clear phone: {'name': 'Ada Lovelace', 'email': '[email protected]', 'phone': '555-0100'}
What this does and why it matters
The function copies the profile with dict(profile) and then overwrites a field only when the caller supplied a value other than None. The rename works: the name changed to “Ada King” and the other fields stayed. The last line is the bug. The caller asked to clear the phone number by passing phone=None, and the function did nothing, because phone=None looks exactly like “the caller did not mention the phone”. The function cannot tell those two requests apart, so one of them is impossible to express. The same collision appears whenever None has a meaning of its own: “no timeout”, “no limit”, “this key exists and holds nothing”, “this user was not found”.
HTTP APIs hit the same wall. My FastAPI mass assignment tutorial shows a PATCH handler where treating an explicit null as “not sent” means a client can no longer clear a property by sending null.
Check that you saw the bug
The last output line must still contain 'phone': '555-0100'. If it shows 'phone': None, you changed the function. Restore the version above before moving on, because the next step builds on this exact behavior.
Step 2: Try the old object() fix and see what it costs
The traditional answer is to create one private object that no caller can know about, and to use it as the default instead of None. A bare object() is a brand-new featureless object, so value is _UNSET is true only when the caller passed nothing. Create step02_object_marker.py:
# step02_object_marker.py
import copy
import inspect
import pickle
from dataclasses import dataclass
_UNSET = object()
def update_profile(profile, *, name=_UNSET, email=_UNSET, phone=_UNSET):
changed = dict(profile)
for key, value in (("name", name), ("email", email), ("phone", phone)):
if value is not _UNSET:
changed[key] = value
return changed
profile = {"name": "Ada Lovelace", "email": "[email protected]", "phone": "555-0100"}
print("clear phone:", update_profile(profile, phone=None))
print("signature: ", inspect.signature(update_profile))
@dataclass
class Settings:
timeout: object = _UNSET
def effective_timeout(settings, default=30.0):
return default if settings.timeout is _UNSET else settings.timeout
original = Settings()
clone = copy.deepcopy(original)
print("original: ", original)
print("original timeout: ", effective_timeout(original))
print("deep copy: ", clone)
print("deep copy timeout:", effective_timeout(clone))
print("pickle round trip keeps identity:", pickle.loads(pickle.dumps(_UNSET)) is _UNSET)
Run python step02_object_marker.py. Your output has the same shape as this, but the memory addresses (the 0x numbers) typically differ from run to run and from machine to machine:
clear phone: {'name': 'Ada Lovelace', 'email': '[email protected]', 'phone': None}
signature: (profile, *, name=<object object at 0x00000163F06C05E0>, email=<object object at 0x00000163F06C05E0>, phone=<object object at 0x00000163F06C05E0>)
original: Settings(timeout=<object object at 0x00000163F06C05E0>)
original timeout: 30.0
deep copy: Settings(timeout=<object object at 0x00000163F06C05F0>)
deep copy timeout: <object object at 0x00000163F06C05F0>
pickle round trip keeps identity: False
What worked
The first line fixes Step 1. update_profile(profile, phone=None) now stores None, because the function compares each argument with _UNSET using is not, and None is no longer mistaken for “missing”.
Cost 1: an unreadable signature
inspect.signature shows what a person sees in help text or an editor tooltip. Each default prints as <object object at 0x...>, which says nothing about its purpose. PEP 661 uses the standard library’s traceback.print_exception as its motivating example and says the marker “has an uninformative and overly verbose repr, causing the function’s signature to be overly long and hard to read”.
Cost 2: copies stop recognizing the marker
The Settings dataclass holds a timeout whose default is the marker, and effective_timeout falls back to 30 seconds while the timeout is still the marker. A deep copy duplicates an object and everything inside it (my deep copy tutorial covers how). It is common to deep copy a configuration object before changing it for one request or one test. The original reports 30.0, but the deep copy reports a bare object, because the copy contains a new object() that is not _UNSET. Code that later hands that value to a function expecting a number would fail somewhere far from the cause. The PEP lists the same drawback: such markers “behave unexpectedly after being copied, due to a separate instance being created and thus comparisons using is failing.”
Cost 3: pickling does the same
Pickle is Python’s way to turn an object into bytes so it can be saved or sent to another process. The last output line shows that unpickling an object() also produces a different object, so is fails again. That matters in the next step.
Step 3: Send the marker to another process
Python programs often split work across several processes with multiprocessing. A common pattern is a worker process that reads jobs from a queue in a loop, and a stop marker (also called a poison pill) that the main program puts on the queue to say “no more jobs, you can exit”. The worker checks each item with is.
Items travel through the queue as pickles. The multiprocessing documentation says: “When an object is put on a queue, the object is pickled and a background thread later flushes the pickled data to an underlying pipe.” So the worker does not receive the object the main program sent, only a copy built from the pickle. Create three small modules. markers.py holds the markers. Its second line uses sentinel(), which you will meet in Step 4, so ignore it for now:
# markers.py
"""Stop markers shared by the process demos: the old idiom and the new builtin side by side."""
OLD_STOP = object()
STOP = sentinel("STOP")
workers.py holds two worker functions, one for each marker:
# workers.py
import markers
def count_jobs_old(jobs, results):
"""Count jobs until the old-style stop marker arrives."""
handled = 0
while True:
item = jobs.get()
if item is markers.OLD_STOP:
break
handled += 1
results.put(handled)
def count_jobs(jobs, results):
"""Count jobs until the sentinel stop marker arrives."""
handled = 0
while True:
item = jobs.get()
if item is markers.STOP:
break
handled += 1
results.put(handled)
harness.py starts one worker, sends three jobs and the stop marker, and reports whether the worker exited within a time limit:
# harness.py
import multiprocessing as mp
def run(worker, stop_marker, job_count=3, wait=3):
"""Start one worker process, send jobs and a stop marker, and report what happened.
Returns (stuck, handled). stuck is True when the worker had not exited after `wait` seconds.
"""
jobs, results = mp.Queue(), mp.Queue()
process = mp.Process(target=worker, args=(jobs, results))
process.start()
for number in range(job_count):
jobs.put(f"job-{number}")
jobs.put(stop_marker)
process.join(timeout=wait)
stuck = process.is_alive()
handled = None
if stuck:
process.terminate()
process.join()
else:
handled = results.get(timeout=wait)
return stuck, handled
Finally, step03_poison_pill_old.py runs the old marker through the harness:
# step03_poison_pill_old.py
import harness
import markers
import workers
if __name__ == "__main__":
stuck, handled = harness.run(workers.count_jobs_old, markers.OLD_STOP)
print("worker still running after 3 s:", stuck)
print("jobs reported by the worker: ", handled)
Run python step03_poison_pill_old.py. It pauses for about three seconds, then prints:
worker still running after 3 s: True
jobs reported by the worker: None
What happened
The worker was still running after three seconds. Its loop can leave only when item is markers.OLD_STOP is true, but the item it received was a copy, so the test failed and the loop went back to waiting for another job. The harness gave up and terminated the process, which is why the job count is None.
The if __name__ == "__main__": guard matters on Windows and macOS, where each worker starts as a fresh Python interpreter that imports your main script again. Without the guard, each new interpreter would run the script’s top-level code again and try to start a worker of its own, and the multiprocessing documentation says that a module written that way fails with a RuntimeError. I ran this on Windows only. The documentation says queue items are pickled, and that does not depend on the platform, so I expect the same copy-instead-of-same-object effect elsewhere, but I did not test it.
Check that you saw the hang
The first output line must say True. A False there means the worker exited, so recheck that markers.py defines OLD_STOP = object() and that workers.py compares against markers.OLD_STOP.
Step 4: Meet the sentinel() builtin
Python 3.15 ships a builtin that removes all three costs. The documentation for the builtin starts with “Return a new unique sentinel object.” and says sentinels are “truthy and compare equal only to themselves. They are intended to be compared with the is operator.” The Python 3.15 What’s New page sums up the rest: “Sentinel objects preserve identity when copied, support use in type expressions with the | operator, and can be pickled when they are importable by module and name.” Create step04_sentinel_basics.py to see each property:
# step04_sentinel_basics.py
import copy
import pickle
import markers
MISSING = sentinel("MISSING")
print("type: ", type(MISSING))
print("repr and str: ", repr(MISSING), str(MISSING))
print("name and module: ", MISSING.__name__, MISSING.__module__)
print("custom repr: ", sentinel("MISSING", repr="<MISSING>"))
print("truthy: ", bool(MISSING))
print("equal to itself: ", MISSING == MISSING)
print("equal to same name: ", MISSING == sentinel("MISSING"))
print("equal to its name: ", MISSING == "MISSING")
print("two calls, two objects:", sentinel("X") is sentinel("X"))
print("usable as a dict key: ", {MISSING: "value"})
print("copy keeps identity: ", copy.copy(MISSING) is MISSING, copy.deepcopy(MISSING) is MISSING)
print("pickle keeps identity: ", pickle.loads(pickle.dumps(markers.STOP)) is markers.STOP)
Run python step04_sentinel_basics.py. Expected output:
type: <class 'sentinel'>
repr and str: MISSING MISSING
name and module: MISSING __main__
custom repr: <MISSING>
truthy: True
equal to itself: True
equal to same name: False
equal to its name: False
two calls, two objects: False
usable as a dict key: {MISSING: 'value'}
copy keeps identity: True True
pickle keeps identity: True
What each line shows
sentinel("MISSING") creates the object. The string you pass is its name, and by default it is also what repr() and str() print, so a signature or a log line shows MISSING instead of a memory address. The optional repr= argument changes that text, as the “custom repr” line shows. The __name__ and __module__ attributes record the name and the module where the sentinel was created. Here that module is __main__, the script you ran. The module matters in a moment, because pickling uses it.
The equality lines show that a sentinel equals only itself. A second sentinel with the same name is a different object, and so is the string "MISSING". The line “two calls, two objects” makes the rule explicit. In the PEP’s words: “Each call to sentinel(name) returns a new sentinel object. If a sentinel is needed in more than one place, it should be assigned to a variable and that same object should be reused explicitly”. In practice you create each sentinel once, at the top of a module, and import it wherever you need it.
The last two lines are the fixes for Step 2. Shallow and deep copies return the object itself (the documentation says so in one sentence), and a pickle round trip returns the same object, here for markers.STOP, which lives at module level in an importable module.
One line deserves a second look: truthy: True. Unlike None, a sentinel counts as true in an if test. The PEP notes that this “parallels the default for arbitrary classes, as well as the boolean value of Ellipsis”. Step 6 shows a bug this causes.
Check it worked
Each line should match the output above. If pickle keeps identity prints False or raises an error, check that markers.py sits in the same folder and contains STOP = sentinel("STOP"), with the variable name matching the string exactly.
Step 5: Learn the rules of sentinel()
A tool you understand is a tool you can debug. This script tries ten things on purpose and prints what Python says for each. Create step05_rules.py:
# step05_rules.py
import json
import pickle
import weakref
def attempt(label, action):
try:
result = action()
except Exception as error:
print(f"{label}: {type(error).__name__}: {error}")
else:
print(f"{label}: ok -> {result!r}")
MISSING = sentinel("MISSING")
ALIAS = sentinel("SOMETHING_ELSE")
class Config:
DEFAULT = sentinel("Config.DEFAULT")
def make_local_sentinel():
return sentinel("LOCAL")
attempt("name must be a string", lambda: sentinel(42))
attempt("no subclassing", lambda: type("Sub", (sentinel,), {}))
attempt("ordering", lambda: MISSING < MISSING)
attempt("weak reference", lambda: weakref.ref(MISSING))
attempt("json", lambda: json.dumps({"limit": MISSING}))
attempt("new attribute", lambda: setattr(MISSING, "note", "hi"))
attempt("pickle module-level", lambda: pickle.loads(pickle.dumps(MISSING)) is MISSING)
attempt("pickle class attribute", lambda: pickle.loads(pickle.dumps(Config.DEFAULT)) is Config.DEFAULT)
attempt("pickle local sentinel", lambda: pickle.dumps(make_local_sentinel()))
attempt("pickle with a different name", lambda: pickle.dumps(ALIAS))
Run python step05_rules.py. Expected output:
name must be a string: TypeError: sentinel() argument 1 must be str, not int
no subclassing: TypeError: type 'sentinel' is not an acceptable base type
ordering: TypeError: '<' not supported between instances of 'sentinel' and 'sentinel'
weak reference: TypeError: cannot create weak reference to 'sentinel' object
json: TypeError: Object of type sentinel is not JSON serializable
new attribute: AttributeError: 'sentinel' object has no attribute 'note' and no __dict__ for setting new attributes
pickle module-level: ok -> True
pickle class attribute: ok -> True
pickle local sentinel: PicklingError: Can't pickle LOCAL: it's not found as __main__.LOCAL
pickle with a different name: PicklingError: Can't pickle SOMETHING_ELSE: it's not found as __main__.SOMETHING_ELSE
Creating a sentinel
The name must be a string, and Python raises TypeError otherwise. The class cannot be subclassed. Both rules keep sentinels simple, because a sentinel is an instance of the builtin class itself and not of a subclass.
What you cannot do with one
Sentinels have no ordering, so MISSING < MISSING raises TypeError; the PEP says “Ordering comparisons are undefined for sentinel objects.” They do not support weak references, and you cannot attach new attributes to them. The JSON line deserves special attention in real code. If a sentinel leaks into a dictionary that you pass to json.dumps, the call fails with Object of type sentinel is not JSON serializable. Filter sentinels out before you serialize, or better, make sure they stay inside the function that interprets them.
When pickling works
The documentation spells out the rule: pickling is supported for sentinels “placed in the global scope of a module under a name matching the sentinel’s name”, and for sentinels in a class whose name matches the qualified name. It adds: “Other sentinels, such as those defined in a function scope, are not picklable.” The mechanism is simple. The PEP explains that “Pickling records the sentinel by module and name. Unpickling then imports the module and retrieves the sentinel by name”. That is why the module-level and class-attribute cases above print True. The last two attempts have nothing to look up: LOCAL was created inside a function, and SOMETHING_ELSE is stored in a variable called ALIAS. Both fail with PicklingError. The practical rule is to give each sentinel a module-level variable with the same name as the string you pass in.
Check it worked
You should see the same ten lines. The wording of an error message can change between Python releases, so compare the exception types first: five lines end in TypeError, one in AttributeError, two in PicklingError, and two print ok -> True.
Step 6: Rewrite the partial update with MISSING
Now fix the bug from Step 1. Create profiles.py. It defines one sentinel and a function that uses it to separate the three states you met at the start:
# profiles.py
MISSING = sentinel("MISSING")
def update_profile(
profile: dict,
*,
name: str | MISSING = MISSING,
email: str | MISSING = MISSING,
phone: str | None | MISSING = MISSING,
) -> dict:
"""Return a copy of profile. Leave a field out to keep it, pass None to clear the phone."""
changed = dict(profile)
if name is not MISSING:
if name is None:
raise ValueError("name cannot be cleared")
changed["name"] = name
if email is not MISSING:
if email is None:
raise ValueError("email cannot be cleared")
changed["email"] = email
if phone is not MISSING:
changed["phone"] = phone
return changed
Then create step06_update_profile.py, which calls the function, prints its signature, repeats the deep copy test from Step 2 with a sentinel, and demonstrates the truthiness trap:
# step06_update_profile.py
import copy
import inspect
from dataclasses import asdict, dataclass
from profiles import MISSING, update_profile
profile = {"name": "Ada Lovelace", "email": "[email protected]", "phone": "555-0100"}
print("keep the phone: ", update_profile(profile, name="Ada King"))
print("set the phone: ", update_profile(profile, phone="555-0199"))
print("clear the phone: ", update_profile(profile, phone=None))
print("signature:", inspect.signature(update_profile))
@dataclass
class Settings:
timeout: float | MISSING = MISSING
def effective_timeout(settings, default=30.0):
return default if settings.timeout is MISSING else settings.timeout
original = Settings()
clone = copy.deepcopy(original)
print("original: ", original)
print("deep copy: ", clone)
print("deep copy timeout:", effective_timeout(clone))
print("asdict keeps it: ", asdict(original)["timeout"] is MISSING)
def greeting(name=MISSING):
return "Hello, " + (name or "friend")
try:
greeting()
except TypeError as error:
print("truthy trap:", error)
def greeting_fixed(name=MISSING):
return "Hello, " + ("friend" if name is MISSING else name)
print("fixed:", greeting_fixed(), "|", greeting_fixed("Ada"))
Run python step06_update_profile.py. Expected output:
keep the phone: {'name': 'Ada King', 'email': '[email protected]', 'phone': '555-0100'}
set the phone: {'name': 'Ada Lovelace', 'email': '[email protected]', 'phone': '555-0199'}
clear the phone: {'name': 'Ada Lovelace', 'email': '[email protected]', 'phone': None}
signature: (profile: dict, *, name: str | MISSING = MISSING, email: str | MISSING = MISSING, phone: str | None | MISSING = MISSING) -> dict
original: Settings(timeout=MISSING)
deep copy: Settings(timeout=MISSING)
deep copy timeout: 30.0
asdict keeps it: True
truthy trap: can only concatenate str (not "sentinel") to str
fixed: Hello, friend | Hello, Ada
What this does and why it matters
Each parameter defaults to MISSING. Inside the function, is not MISSING means “the caller said something about this field”. For phone that can be a string or None, and None is now stored, so the first three lines show the three behaviors that were impossible in Step 1: leave the phone alone, set it, and clear it. For name and email the function treats None as a mistake and raises ValueError, which the tests in Step 12 check. A sentinel lets one function give None different meanings for different parameters.
The annotations use the sentinel inside a type expression: str | MISSING reads “a string, or the MISSING marker”, and str | None | MISSING adds None for the phone. The printed signature is now readable: name: str | MISSING = MISSING. That is the first cost from Step 2, gone.
The Settings dataclass repeats the Step 2 experiment. The dataclass prints as Settings(timeout=MISSING), the deep copy still reports the 30-second fallback, and dataclasses.asdict also keeps the identity. That is the second cost, gone. The third cost, pickling, is the one that matters for the worker process in Step 8.
The truthiness trap
The last block shows the mistake that the “truthy” line in Step 4 predicted. People write name or "friend" to pick a fallback, and that idiom relies on a missing value being falsy. A sentinel is truthy, so name or "friend" returns the sentinel itself, and the string concatenation fails with can only concatenate str (not "sentinel") to str. The fix is the comparison name is MISSING. The PEP gives the same advice: “Identity checks such as if value is MISSING: should usually be used rather than boolean checks such as if value: or if not value:.”
Check it worked
The “clear the phone” line must show 'phone': None, and the “keep the phone” line must still show '555-0100'. If both show the old phone, the function is comparing against None instead of MISSING.
Step 7: Make a cache remember “not found”
Caches are the second classic home for this problem. A lookup that finds nothing returns None, and a cache that uses None to mean “not cached yet” cannot store that answer. Create directory.py, which contains a pretend slow lookup service and two caches, one for each marker:
# directory.py
MISSING = sentinel("MISSING")
USERS = {"[email protected]": "Ada Lovelace", "[email protected]": "Grace Hopper"}
class Directory:
"""A slow lookup service. It returns None for users it does not know."""
def __init__(self):
self.calls = 0
def find(self, email):
self.calls += 1
return USERS.get(email)
def cached_with_none(directory):
"""Cache that uses None to mean 'not cached yet'."""
cache = {}
def lookup(email):
hit = cache.get(email)
if hit is not None:
return hit
cache[email] = directory.find(email)
return cache[email]
return lookup
def cached_with_sentinel(directory):
"""Cache that uses MISSING to mean 'not cached yet', so a stored None is a real answer."""
cache = {}
def lookup(email):
hit = cache.get(email, MISSING)
if hit is not MISSING:
return hit
cache[email] = directory.find(email)
return cache[email]
return lookup
Create step07_negative_cache.py. It sends 20 requests, made of two known and two unknown email addresses repeated five times, through each cache and counts how many times the slow service was called:
# step07_negative_cache.py
from directory import Directory, cached_with_none, cached_with_sentinel
REQUESTS = ["[email protected]", "[email protected]", "[email protected]", "[email protected]"] * 5
for label, make_lookup in (
("None as the miss marker ", cached_with_none),
("sentinel as the miss marker", cached_with_sentinel),
):
directory = Directory()
lookup = make_lookup(directory)
answers = [lookup(email) for email in REQUESTS]
print(f"{label}: {len(REQUESTS)} requests, {directory.calls} directory calls")
Run python step07_negative_cache.py. Expected output:
None as the miss marker : 20 requests, 12 directory calls
sentinel as the miss marker: 20 requests, 4 directory calls
Why the numbers differ
The arithmetic is short. The four distinct addresses are requested five times each. With None as the miss marker, the two known users are fetched once and then served from the cache, which is 2 calls. The two unknown users produce None, the cache cannot tell that from “not cached”, and each of their five requests goes back to the service, which is 10 more calls: 12 in total. With the sentinel, every distinct address is fetched once: 4 calls. Remembering a miss is often called negative caching, and it protects a slow backend from repeated questions about things that do not exist.
The sentinel version also reads the cache once, with cache.get(email, MISSING). The second argument to dict.get is a natural place for a sentinel, because it lets you tell “the key is absent” from “the key holds None” in a single lookup. If you want to go further with cache design, my cache stampede tutorial covers what happens when many requests miss at the same moment.
Check it worked
You should see 12 calls for the None cache and 4 for the sentinel cache. If both show 4, the first cache is not the one using None; if both show 12, the second one is comparing the cached value against None.
Step 8: Stop the worker process with a sentinel
Return to the process problem from Step 3. The harness and the workers are already written, and markers.py already defines STOP = sentinel("STOP"). Create step08_poison_pill_sentinel.py, which differs from Step 3 only in the worker function and the marker:
# step08_poison_pill_sentinel.py
import harness
import markers
import workers
if __name__ == "__main__":
stuck, handled = harness.run(workers.count_jobs, markers.STOP)
print("worker still running after 3 s:", stuck)
print("jobs reported by the worker: ", handled)
Run python step08_poison_pill_sentinel.py. Expected output, which this time appears quickly:
worker still running after 3 s: False
jobs reported by the worker: 3
Why this works
When the main program puts markers.STOP on the queue, pickle records the module name markers and the name STOP. In the worker process, unpickling imports markers and looks up STOP, which yields the sentinel that exists in that process. The test item is markers.STOP is then true even though the object traveled through a pipe. The worker leaves its loop, reports the three jobs it handled, and exits within the time limit, so stuck is False. This is the pickling rule from Step 5 doing useful work: the sentinel lives at module level in a module both processes can import.
Check it worked
The first line must say False and the second must say 3. If the process hangs, confirm that workers.count_jobs compares against markers.STOP and that the main program puts that same markers.STOP on the queue. A second STOP = sentinel("STOP") defined somewhere else would be a different sentinel, and the comparison would fail.
Step 9: Let the type checker help
The PEP says a sentinel can appear in a type expression and stands for “a fully static type that has a single member, the sentinel object itself”, so the checker can understand it. For that to work at runtime, the sentinel must also support the | operator, which the PEP calls out. Create step09_typing.py to see what Python itself does with such an annotation:
# step09_typing.py
import typing
MISSING = sentinel("MISSING")
def next_value(default: int | MISSING = MISSING) -> int:
if default is MISSING:
return 0
return default
hints = typing.get_type_hints(next_value)
print("union at runtime:", int | MISSING)
print("parameter hint: ", hints["default"])
print("members: ", typing.get_args(hints["default"]))
print("call with a value:", next_value(7), "| without:", next_value())
Run python step09_typing.py. Expected output:
union at runtime: int | MISSING
parameter hint: int | MISSING
members: (<class 'int'>, MISSING)
call with a value: 7 | without: 0
The expression int | MISSING evaluates to a union that prints as int | MISSING, and typing.get_type_hints returns the same union for the parameter. typing.get_args splits it into the class int and the sentinel.
Ask mypy what it infers
Runtime behavior is only half of it. The reason to annotate is that a type checker can follow the is MISSING test and know, inside each branch, what the value can be. Create typecheck_demo.py. This file is for mypy to read, not for Python to run, because reveal_type is a mypy helper:
# typecheck_demo.py
MISSING = sentinel("MISSING")
def shorten(value: int | MISSING) -> int:
if value is MISSING:
reveal_type(value)
return 0
reveal_type(value)
return value
Install mypy with python -m pip install mypy and run it. The --python-version flag is only needed when mypy itself runs on an older Python:
python -m mypy --python-version 3.15 --no-error-summary typecheck_demo.py
I got this with mypy 2.4.0:
typecheck_demo.py:7: note: Revealed type is "MISSING"
typecheck_demo.py:9: note: Revealed type is "int"
Inside if value is MISSING: mypy treats the value as exactly MISSING, and after the early return it treats it as int. The PEP states that “Type checkers should support narrowing union types involving sentinels using the is and is not operators”, and mypy 2.4.0 does. If you tell mypy to check against Python 3.14 instead, the builtin does not exist for that version, and the same file produces errors:
python -m mypy --python-version 3.14 --no-error-summary typecheck_demo.py
typecheck_demo.py:2: error: Name "sentinel" is not defined [name-defined]
typecheck_demo.py:5: error: Variable "typecheck_demo.MISSING" is not valid as a type [valid-type]
typecheck_demo.py:5: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases
typecheck_demo.py:7: note: Revealed type is "Any"
typecheck_demo.py:9: note: Revealed type is "int | MISSING?"
The first error says what is wrong: sentinel is not defined when the target is 3.14. The other lines are consequences of that error. Keep this in mind when a project’s checker is configured for an older Python than the one you run.
Check it worked
With the 3.15 flag you should see exactly two notes and no errors. If you see Name "sentinel" is not defined, your mypy is targeting an older Python version than 3.15.
Step 10: Support Python versions before 3.15
If your code must also run on Python 3.13 or 3.14, the typing-extensions package carries the same feature. The typing-extensions documentation records a rename that matters here: “Sentinel has been renamed to sentinel. Sentinel is retained as a soft-deprecated alias, for backwards compatibility.” It also says that the default repr of X = sentinel("X") is now X rather than <X>, and that, as of version 4.16.0, sentinels can be pickled and preserve identity when copied. So you want typing-extensions 4.16.0 or later. Install it on the older interpreter with python -m pip install "typing-extensions>=4.16.0", then create step10_backport.py:
# step10_backport.py
import builtins
import copy
import pickle
import sys
try:
sentinel = builtins.sentinel
source = "builtin sentinel"
except AttributeError:
from typing_extensions import sentinel # typing-extensions 4.16.0 or later
source = "typing_extensions.sentinel"
MISSING = sentinel("MISSING")
print("python: ", sys.version_info[:2], "using", source)
print("repr: ", repr(MISSING))
print("custom repr: ", repr(sentinel("MISSING", repr="<MISSING>")))
print("truthy: ", bool(MISSING))
print("copy keeps identity: ", copy.copy(MISSING) is MISSING, copy.deepcopy(MISSING) is MISSING)
print("pickle keeps identity:", pickle.loads(pickle.dumps(MISSING)) is MISSING)
print("union: ", int | MISSING)
if source.startswith("typing_extensions"):
import typing_extensions
print("old name still works: ", typing_extensions.Sentinel is sentinel)
The try block uses the builtin when it exists, and the except AttributeError branch imports the backport otherwise. The rest of the file is the same for both. I ran the file on Python 3.15.0 first:
python: (3, 15) using builtin sentinel
repr: MISSING
custom repr: <MISSING>
truthy: True
copy keeps identity: True True
pickle keeps identity: True
union: int | MISSING
And on Python 3.13.14 with typing-extensions 4.16.0:
python: (3, 13) using typing_extensions.sentinel
repr: MISSING
custom repr: <MISSING>
truthy: True
copy keeps identity: True True
pickle keeps identity: True
union: typing.Union[int, MISSING]
old name still works: True
What to notice
The behavior matches: the same short repr, truthiness, copy and pickle identity. Two small differences are cosmetic. A union prints as typing.Union[int, MISSING] on 3.13 and as int | MISSING on 3.15, and on the older interpreter the extra line confirms that the old Sentinel name is still importable. If your project pins an older typing-extensions, expect the older repr and no pickling, according to the documentation quoted above; I tested only 4.16.0.
Type checking works the same way for the backport. This file differs from typecheck_demo.py only in where the sentinel comes from:
# typecheck_te.py
from typing_extensions import sentinel
MISSING = sentinel("MISSING")
def shorten(value: int | MISSING) -> int:
if value is MISSING:
reveal_type(value)
return 0
reveal_type(value)
return value
python -m mypy --python-version 3.13 --no-error-summary typecheck_te.py
typecheck_te.py:9: note: Revealed type is "MISSING"
typecheck_te.py:11: note: Revealed type is "int"
Step 11: Check your code base for names that collide
Adding a builtin means that existing code can contain a variable with the same name. The PEP covers this in its backwards compatibility section: code that relies on the bare name sentinel raising NameError will see the new builtin instead, and “Existing local, global, and imported names called sentinel are unaffected.” So old code keeps running. A linter is another matter. Some projects already have a variable called sentinel, because it is a natural name for the queue stop marker from Step 3. Create shadow_demo.py with that familiar pattern:
# shadow_demo.py
import queue
sentinel = object()
def drain(jobs: queue.Queue, sentinel=sentinel):
while (item := jobs.get()) is not sentinel:
print(item)
Ruff’s builtin-shadowing rules are A001 for variables and A002 for function arguments. Run them once for a project that targets Python 3.14 and once for a project that targets 3.15:
python -m ruff check --isolated --target-version py314 --select A001,A002 --output-format concise shadow_demo.py
python -m ruff check --isolated --target-version py315 --select A001,A002 --output-format concise shadow_demo.py
With Ruff 0.17.1, the first command passes:
All checks passed!
and the second reports both names:
shadow_demo.py:4:1: A001 Variable `sentinel` is shadowing a Python builtin
shadow_demo.py:7:30: A002 Function argument `sentinel` is shadowing a Python builtin
Found 2 errors.
What this means for you
The code is still legal and still behaves the same. What changes is the linter’s opinion once your configured target version reaches 3.15. Ruff’s documentation explains why the rule exists: reusing a builtin name “increases the difficulty of reading and maintaining the code, and can cause non-obvious errors, as readers may mistake the variable for the builtin and vice versa.” You have three options. Rename the variable, which is the better choice. Replace the object() with a real sentinel() under a descriptive name such as STOP, as you did in markers.py. Or add the name to the rule’s lint.flake8-builtins.ignorelist setting, which Ruff’s documentation describes, if you cannot touch the code yet. I ran only these two rules, selected explicitly, so check which rules your own configuration enables.
Step 12: Lock the behavior in with tests
Everything so far was a script you read by eye. Tests keep the behavior from drifting. Create test_sentinels.py. It checks the profile update, the truthiness and identity rules, the type hints, both caches, and both stop markers, including the one that is expected to hang:
# test_sentinels.py
import copy
import inspect
import pickle
import typing
import pytest
import harness
import markers
import workers
from directory import Directory, cached_with_none, cached_with_sentinel
from profiles import MISSING, update_profile
PROFILE = {"name": "Ada Lovelace", "email": "[email protected]", "phone": "555-0100"}
REQUESTS = ["[email protected]", "[email protected]", "[email protected]", "[email protected]"] * 5
def test_leaving_a_field_out_keeps_it():
assert update_profile(PROFILE, name="Ada King") == {**PROFILE, "name": "Ada King"}
def test_none_clears_the_phone():
assert update_profile(PROFILE, phone=None)["phone"] is None
def test_none_is_rejected_for_required_fields():
with pytest.raises(ValueError, match="name cannot be cleared"):
update_profile(PROFILE, name=None)
def test_sentinel_is_truthy_and_equal_only_to_itself():
assert bool(MISSING) is True
assert MISSING == MISSING
assert MISSING != sentinel("MISSING")
def test_copy_deepcopy_and_pickle_keep_identity():
assert copy.copy(MISSING) is MISSING
assert copy.deepcopy(MISSING) is MISSING
assert pickle.loads(pickle.dumps(markers.STOP)) is markers.STOP
def test_a_sentinel_made_inside_a_function_cannot_be_pickled():
def make():
return sentinel("LOCAL")
with pytest.raises(pickle.PicklingError):
pickle.dumps(make())
def test_signature_shows_the_short_repr():
text = str(inspect.signature(update_profile))
assert "name: str | MISSING = MISSING" in text
assert "object at" not in text
def test_union_annotation_is_readable_at_runtime():
hints = typing.get_type_hints(update_profile)
assert typing.get_args(hints["phone"]) == (str, type(None), MISSING)
def test_cache_with_none_asks_the_directory_again_for_unknown_users():
directory = Directory()
lookup = cached_with_none(directory)
[lookup(email) for email in REQUESTS]
assert directory.calls == 12
def test_cache_with_sentinel_remembers_unknown_users():
directory = Directory()
lookup = cached_with_sentinel(directory)
[lookup(email) for email in REQUESTS]
assert directory.calls == 4
def test_sentinel_stop_marker_ends_a_worker_process():
stuck, handled = harness.run(workers.count_jobs, markers.STOP)
assert stuck is False
assert handled == 3
def test_object_stop_marker_leaves_the_worker_waiting():
stuck, handled = harness.run(workers.count_jobs_old, markers.OLD_STOP, wait=2)
assert stuck is True
assert handled is None
Install pytest with python -m pip install pytest and run python -m pytest -v in the folder. Expected output (your timing and paths differ):
test_sentinels.py::test_leaving_a_field_out_keeps_it PASSED [ 8%]
test_sentinels.py::test_none_clears_the_phone PASSED [ 16%]
test_sentinels.py::test_none_is_rejected_for_required_fields PASSED [ 25%]
test_sentinels.py::test_sentinel_is_truthy_and_equal_only_to_itself PASSED [ 33%]
test_sentinels.py::test_copy_deepcopy_and_pickle_keep_identity PASSED [ 41%]
test_sentinels.py::test_a_sentinel_made_inside_a_function_cannot_be_pickled PASSED [ 50%]
test_sentinels.py::test_signature_shows_the_short_repr PASSED [ 58%]
test_sentinels.py::test_union_annotation_is_readable_at_runtime PASSED [ 66%]
test_sentinels.py::test_cache_with_none_asks_the_directory_again_for_unknown_users PASSED [ 75%]
test_sentinels.py::test_cache_with_sentinel_remembers_unknown_users PASSED [ 83%]
test_sentinels.py::test_sentinel_stop_marker_ends_a_worker_process PASSED [ 91%]
test_sentinels.py::test_object_stop_marker_leaves_the_worker_waiting PASSED [100%]
======================================= 12 passed in 2.14s ========================================
What the tests protect
Most tests are one assertion about one rule from the steps above. Two of them deserve attention. test_a_sentinel_made_inside_a_function_cannot_be_pickled documents the pickling rule from Step 5, so an accidental move of a sentinel into a function body fails loudly instead of failing later in a worker process. The two process tests run the harness for real: the sentinel marker must end the worker, and the object() marker must leave it waiting. The second test shortens the wait to two seconds, so the suite stays quick. Keeping a test for the old behavior is deliberate. It records why the sentinel exists, and it would flag a change in how the old idiom behaves.
Check it worked
The last line must say 12 passed. If a process test fails, run python step08_poison_pill_sentinel.py on its own to see whether the worker hangs, then recheck markers.py as in Step 8.
Confirm everything works end to end
Run the steps in order and compare each result with the output shown above: step01 to step09, then step10_backport.py on both a 3.15 and an older interpreter, then the mypy and Ruff commands, then python -m pytest -v. To test the article itself, I rebuilt the whole lab from the code blocks on this page in an empty folder, ran every step, and compared the results with the output blocks above. Only memory addresses and timings differed, and those are the values that change between runs.
Common mistakes
Using truthiness or == instead of is
Write if value is MISSING. The forms if not value and value or default treat a sentinel as true, so the sentinel slips through, as in Step 6.
Creating a sentinel inside a function
Each call to sentinel("X") makes a different object, so a sentinel created per call does not match the one stored elsewhere. It also cannot be pickled, as Step 5 shows. Define it once at module level, with the variable name equal to the string.
Letting a sentinel escape
A sentinel is an instruction to your own code, not data. If it ends up in a JSON response, a database row, or a log line that someone parses, you have a bug, and json.dumps will refuse it. Convert it to a real value, or drop the field, at the boundary of the function that understands it.
Forgetting that None is still a value
Once a function accepts MISSING, decide per parameter what None means. In update_profile it clears the phone and is rejected for the name. Say so in the docstring, and test both.
Where to go next
For worker threads rather than processes, Python 3.13 added queue.Queue.shutdown(), which lets worker loops end by catching queue.ShutDown instead of looking for a marker object. The queue documentation says the method puts a queue “into a shutdown mode”: “The queue can no longer grow. Future calls to put() raise ShutDown.” It marks the method “Added in version 3.13”. The process queues are different. This short check, check_queues.py, compares the two on Python 3.15.0:
# check_queues.py
import multiprocessing
import queue
print("queue.Queue has shutdown(): ", hasattr(queue.Queue, "shutdown"))
print("multiprocessing.Queue has shutdown():", hasattr(multiprocessing.Queue(), "shutdown"))
queue.Queue has shutdown(): True
multiprocessing.Queue has shutdown(): False
So a stop marker is still the tool for multiprocessing queues, and, as Step 8 showed, a sentinel works well as that marker.
To keep learning, read PEP 661 for the full design and the alternatives it rejected, including single-value enums and a class decorator. Another Python 3.15 builtin that removes a copy-and-compare problem is frozendict, which my frozendict tutorial covers, and my lazy imports tutorial covers the 3.15 feature that moves import cost out of startup. Try one exercise on your own code: search your project for = object() and for default arguments of None where None is also a valid input, and see which of them would be clearer with a named sentinel.








No Comment! Be the first one.