_MISSING = object() で作った番兵は、copy.deepcopy を通したあとも is _MISSING で判定できるのか。答えは False で、pickle を通しても結果は変わりません。Python 3.15.0 で組み込みになった sentinel() は、この同一性の崩れと help() に出るアドレス表記を直すために入りました。
確認環境は Python 3.15.0(2026-10-09 リリース、python-build-standalone 20261013、macOS arm64)と 3.13.15。型チェッカーは mypy 2.4.0 と pyright 1.1.414、バックポートは typing_extensions 4.16.0 / 4.15.0 で試しています。
object()の番兵はdeepcopyとpickleで別物になる
dataclassをdeepcopyするとis判定が外れる
部分更新 API で「email キーが来なかった」と「email に null が来た」を分ける場面です。None は「消す」の意味に使うので、省略の印には別の値が要ります。
import copy
import pickle
from dataclasses import dataclass
_UNSET = object()
UNSET = sentinel("UNSET")
@dataclass
class OldPatch:
email: object = _UNSET
@dataclass
class NewPatch:
email: object = UNSET
print("deepcopy object():", copy.deepcopy(OldPatch()).email is _UNSET)
print("deepcopy sentinel:", copy.deepcopy(NewPatch()).email is UNSET)
print("pickle object():", pickle.loads(pickle.dumps(OldPatch())).email is _UNSET)
print("pickle sentinel:", pickle.loads(pickle.dumps(NewPatch())).email is UNSET)
print(len(pickle.dumps(UNSET)), pickle.dumps(UNSET))
実行結果。
deepcopy object(): False
deepcopy sentinel: True
pickle object(): False
pickle sentinel: True
33 b'\x80\x05\x95\x16\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x05UNSET\x94\x93\x94.'
object() のインスタンスは、copy でも pickle でも新しいオブジェクトとして作り直されます。PEP 661 に載っている挙動のスケッチでは、__copy__ と __deepcopy__ が自分自身を返します。__reduce__ が返すのは名前の文字列だけ。pickle.dumps(UNSET) は 33バイトで、中に入っている文字列は __main__ と UNSET の2つだけでした。
ProcessPoolExecutorに渡すとFalseが返る
concurrent.futures は、子プロセスへ引数を送るときに pickle を使います。番兵を1つ渡し、子プロセス側で is 判定させました。
from concurrent.futures import ProcessPoolExecutor
import multiprocessing as mp
_UNSET = object()
UNSET = sentinel("UNSET")
def is_old_unset(v):
return v is _UNSET
def is_new_unset(v):
return v is UNSET
if __name__ == "__main__":
print("start method:", mp.get_start_method())
with ProcessPoolExecutor(max_workers=1) as ex:
print("object():", ex.submit(is_old_unset, _UNSET).result())
print("sentinel:", ex.submit(is_new_unset, UNSET).result())
実行結果。
start method: spawn
object(): False
sentinel: True
macOS の既定の開始方式は spawn で、子プロセスはモジュールを読み直して UNSET も作り直します。それでも sentinel 側が True になるのは、pickle が「__main__ の UNSET」という名前で子プロセス側のオブジェクトを引き直すから。スレッドは pickle を通りません。Python ThreadPoolExecutor入門—効かない処理とbuffersize で扱った ThreadPoolExecutor に差し替えると、object() 版も True でした。
help()とsignatureに出るアドレス
import inspect
_OLD = object()
MISSING = sentinel("MISSING")
def old_get(key, default=_OLD): ...
def new_get(key, default=MISSING): ...
print(inspect.signature(old_get))
print(inspect.signature(new_get))
実行結果。
(key, default=<object object at 0x1042f8620>)
(key, default=MISSING)
PEP 661 の “Motivation” は、traceback.print_exception の help() に <object object at 0x000002825DF09650> が2回並ぶ例から始まります。sentinel() なら同じ位置に MISSING の7文字が出るだけです。
sentinel()は名前を1つ渡せば作れる
Built-in Functions の sentinel 項は “Return a new unique sentinel object.” で始まり、”Sentinel objects are truthy and compare equal only to themselves.” と続きます。シグネチャは class sentinel(name, /, *, repr=None) で、import は要りません。
nameとreprの2引数
import weakref
_UNSPECIFIED = sentinel("_UNSPECIFIED", repr="<not specified>")
MISSING = sentinel("MISSING")
print(repr(_UNSPECIFIED), _UNSPECIFIED.__name__, _UNSPECIFIED.__module__)
print(type(MISSING), bool(MISSING), MISSING == MISSING, MISSING == "MISSING")
print(sentinel("MISSING") is sentinel("MISSING"))
for label, fn in [
("subclass", lambda: type("Sub", (sentinel,), {})),
("weakref", lambda: weakref.ref(MISSING)),
("ordering", lambda: MISSING < 1),
("name=1", lambda: sentinel(1)),
]:
try:
fn()
except TypeError as e:
print(f"{label}: {e}")
実行結果。
<not specified> _UNSPECIFIED __main__
<class 'sentinel'> True True False
False
subclass: type 'sentinel' is not an acceptable base type
weakref: cannot create weak reference to 'sentinel' object
ordering: '<' not supported between instances of 'sentinel' and 'int'
name=1: sentinel() argument 1 must be str, not int
name は位置専用、repr はキーワード専用。repr= を渡すと表示だけが変わり、__name__ は name のまま残ります。3.15 の urllib.parse にある _UNSPECIFIED が、ちょうどこの書き方。同じ名前で2回呼ぶと別のオブジェクトになるので、番兵は変数に入れて使い回します。
object()・Enum・クラスとの比較
番兵の書き方4通りを、同じスクリプトで copy・deepcopy・pickle に通した結果です。
| 書き方 | repr | copy / deepcopy | pickle | 型ヒント |
|---|---|---|---|---|
object() | <object object at 0x…> | 別物になる | 別物になる | object としか書けない |
単一メンバーの Enum | <_NotGivenType.NotGiven: 'NotGiven'> | 同一 | 同一 | Literal[_NotGivenType.NotGiven] |
| クラスそのもの | <class '__main__.ClassSentinel'> | 同一 | 同一 | type[ClassSentinel] |
sentinel() | SENT | 同一 | 同一 | SENT |
4つとも bool() は True でした。PEP 661 の “Rejected Ideas” は Enum 案を、書き方の繰り返しが多いこと、repr が長いこと、投票で9案中ただ1つ票が入らなかったことを理由に退けています。
iter()の番兵法とは別の話
サジェストに出る「python sentinel loop」や「番兵法」は、終端の値で繰り返しを止める書き方を指します。Python では iter(callable, sentinel) の2引数形がこれにあたります。
import io
f = io.BytesIO(b"abcdefghij")
for chunk in iter(lambda: f.read(4), b""):
print(chunk)
実行結果。
b'abcd'
b'efgh'
b'ij'
ここでの sentinel は仮引数の名前で、渡すのは b"" のような普通の値。組み込みの sentinel 型は関わりません。
pickleできるのはモジュールとクラスの直下だけ
番兵を関数の中で作ると、ProcessPoolExecutor に渡した時点で落ちます。pickle は __module__ と名前でオブジェクトを引き直すので、その名前で辿れない番兵は送れません。
関数の中で作るとPicklingError
import pickle
def make():
LOCAL = sentinel("LOCAL")
return LOCAL
MISMATCH = sentinel("Mismatch")
for label, value in [("local", make()), ("mismatch", MISMATCH)]:
try:
pickle.dumps(value)
except pickle.PicklingError as e:
print(f"{label}: {e}")
実行結果。
local: Can't pickle LOCAL: it's not found as __main__.LOCAL
mismatch: Can't pickle Mismatch: it's not found as __main__.Mismatch
LOCAL は make() のローカル変数なので、__main__.LOCAL では辿れない。公式ドキュメントも “Other sentinels, such as those defined in a function scope, are not picklable.” と書いています。
変数名とnameを揃える
出力の2行目は、変数名 MISMATCH と name の "Mismatch" がずれたケース。pickle は name の側で探すので、モジュール直下にあっても見つかりません。変数名と name は同じ綴りで書きます。
クラス属性ならqualnameを渡す
公式ドキュメントは、クラスに置く例として class Cls: PICKLABLE = sentinel("Cls.PICKLABLE") を載せています。name にクラス名込みの修飾名を渡す形です。
import pickle
class Field:
MISSING = sentinel("Field.MISSING")
print(repr(Field.MISSING))
print(pickle.loads(pickle.dumps(Field.MISSING)) is Field.MISSING)
実行結果。
Field.MISSING
True
実行時はこれで動きます。ただ pyright 1.1.414 は、同じファイルに次のエラーを出しました。
s8_class.py:4:5 - error: Sentinel must be assigned to a variable with the same name (reportGeneralTypeIssues)
1 error, 0 warnings, 0 informations
mypy 2.4.0 は通します。CI で pyright を回しているなら、番兵はモジュール直下に NAME = sentinel("NAME") の形で置く。この形なら pyright も mypy もエラーを出しません。
型ヒントにint | MISSINGと書ける
PEP 661 の “Typing” 節は “Sentinel objects may be used in type expressions, representing themselves.” と定めています。int | None の None と同じ扱いで、番兵1つだけを値に持つ型になります。
isで絞り込める
from typing import assert_type
MISSING = sentinel("MISSING")
def get_timeout(value: int | None | MISSING = MISSING) -> int | None:
if value is MISSING:
assert_type(value, MISSING)
return 30
assert_type(value, int | None)
return value
def bad(value: int | MISSING = MISSING) -> int:
return value + 1
get_timeout(None)
get_timeout("10")
mypy 2.4.0 の結果。
s9_typing.py:13: error: Unsupported operand types for + ("MISSING" and "int") [operator]
s9_typing.py:13: note: Left operand is of type "int | MISSING"
s9_typing.py:16: error: Argument 1 to "get_timeout" has incompatible type "Literal['10']"; expected "int | MISSING | None" [arg-type]
Found 2 errors in 1 file (checked 1 source file)
pyright 1.1.414 の結果。
s9_typing.py:13:12 - error: Operator "+" not supported for types "int | MISSING" and "Literal[1]"
s9_typing.py:16:13 - error: Argument of type "Literal['10']" cannot be assigned to parameter "value" of type "int | MISSING | None" in function "get_timeout"
2 errors, 0 warnings, 0 informations
どちらも is MISSING の分岐で型を絞り込み、assert_type は2か所とも通りました。bad() の value + 1 と、文字列を渡した呼び出しは両方ともエラー。mypy は 2.4 の CHANGELOG に “Support for PEP 661 Sentinels” の節を設けていて、2.4.0 は 2026-10-01 に出ています。
名前の不一致はpyrightだけが止める
from typing import reveal_type
Wrong = sentinel("Other")
def f(v: int | Wrong) -> None:
reveal_type(v)
mypy 2.4.0 の結果。
s10_mismatch.py:6: note: Revealed type is "int | Wrong"
Success: no issues found in 1 source file
pyright 1.1.414 の結果。
s10_mismatch.py:3:1 - error: Sentinel must be assigned to a variable with the same name (reportGeneralTypeIssues)
s10_mismatch.py:5:16 - error: Variable not allowed in type expression (reportInvalidTypeForm)
s10_mismatch.py:6:17 - information: Type of "v" is "int | Unknown"
2 errors, 0 warnings, 1 information
PEP 661 は、変数名と name が一致しないとき “type checkers should emit an error” と書いています。pyright はこれに従い、注釈側の Wrong を Unknown に落としました。mypy 2.4.0 は何も言わず、Wrong をそのまま型として扱う。mypy だけの環境だと、名前のずれは pickle した時点まで表に出ません。
if notとisinstanceでは判定できない
番兵は truthy です。None のつもりで if not v: と書くと、番兵はそこをすり抜けます。
MISSING = sentinel("MISSING")
def label(v: str | MISSING = MISSING) -> str:
if not v:
return "(empty)"
return v
print(repr(label("")), repr(label()))
実行結果。
'(empty)' MISSING
label() は番兵そのものを返しました。ここは両方のチェッカーが止めます。mypy は Incompatible return value type (got "str | MISSING", expected "str")、pyright は "MISSING" is not assignable to "str"。戻り値の型を書いてあれば CI で拾えます。
isinstance に int | MISSING を渡すと、値によって結果が割れます。
MISSING = sentinel("MISSING")
for v in [1, "a", MISSING]:
try:
print(repr(v), isinstance(v, int | MISSING))
except TypeError as e:
print(repr(v), e)
実行結果。
1 True
'a' isinstance() arg 2 must be a type, a tuple of types, or a union
MISSING isinstance() arg 2 must be a type, a tuple of types, or a union
実行時の int | MISSING は typing.Union です。int に一致する値は True、それ以外の値は TypeError。整数しか流さないテストでは通ってしまうので、v is MISSING or isinstance(v, int) と分けて書きます。
match文のcase MISSINGは全部に一致する
None・番兵・値の3分岐を match で書くと、case None: の次に case MISSING: を並べたくなります。ただ case MISSING: は番兵との比較になりません。
最後のcaseに置くと42もMISSINGになる
MISSING = sentinel("MISSING")
def describe(v: int | None | MISSING) -> str:
match v:
case None:
return "None"
case MISSING:
return "MISSING"
print(describe(42))
実行結果。
MISSING
describe(42) が "MISSING" を返しました。PEP 634 の構造的パターンマッチでは、ドットを含まない名前は capture pattern(キャプチャパターン)になります。どんな値にも一致し、その値を MISSING というローカル変数に代入するだけ。None・True・False はキーワードなので literal pattern として比較されます。番兵は普通の名前なので、None と同じ書き方をすると capture pattern に化けます。
途中の case に置くと、今度はコンパイル時に SyntaxError。
MISSING = sentinel("MISSING")
def f(v):
match v:
case MISSING:
return 1
case _:
return 2
実行結果。
File "/private/tmp/sentinel-lab/article/s14_match_syntax.py", line 4
case MISSING:
^^^^^^^
SyntaxError: name capture 'MISSING' makes remaining patterns unreachable
止まるのは、後ろに case が続くときだけです。最後に置くと、警告なしで全部の値を拾います。
mypyとpyrightは通し、ruffのF841が拾う
型注釈に MISSING を入れた上の describe() を、両方のチェッカーに通しました。
$ mypy --python-version 3.15 s13_match_bad.py
Success: no issues found in 1 source file
$ pyright --pythonversion 3.15 s13_match_bad.py
0 errors, 0 warnings, 0 informations
型の上では、None 以外の値をローカル変数 MISSING に代入しただけ。戻り値もすべての経路で str なので、型の矛盾が起きません。拾ったのは ruff 0.17.1 の既定ルールでした。
$ ruff check --target-version py315 s13_match_bad.py
F841 Local variable `MISSING` is assigned to but never used
--> s13_match_bad.py:7:14
|
5 | case None:
6 | return "None"
7 | case MISSING:
| ^^^^^^^
8 | return "MISSING"
|
help: Remove assignment to unused variable `MISSING`
Found 1 error.
F841 は「代入したのに使っていない変数」の検出。case MISSING: の本体で return f"{MISSING}" のように名前を参照すると、この警告も消えます。ruff が拾えるのは、本体で名前を使っていないときだけです。
matchの前にisで抜ける
ドット付きの名前は value pattern になり、== で比較されます。sentinel の == は自分自身とだけ True なので、番兵を別モジュールに置けば case に書ける。
# markers.py
MISSING = sentinel("MISSING")
# use_mod.py
import markers
def describe(v: int | None | markers.MISSING) -> str:
match v:
case None:
return "None"
case markers.MISSING:
return "MISSING"
case _:
return f"value={v}"
print([describe(v) for v in (None, markers.MISSING, 42)])
実行結果と、pyright 1.1.414 の結果。
['None', 'MISSING', 'value=42']
$ pyright --pythonversion 3.15 use_mod.py
use_mod.py:3:30 - error: Expected class but received "MISSING" (reportGeneralTypeIssues)
1 error, 0 warnings, 0 informations
実行時は3つとも正しく分かれ、mypy 2.4.0 も通す。pyright は注釈の markers.MISSING を型として受け付けません。クラス属性の Field.MISSING も、前の節のとおり定義の時点でエラー。
両方のチェッカーで素直に通ったのは、match に入る前に is で番兵を外す書き方。
from typing import assert_type
MISSING = sentinel("MISSING")
def describe(v: int | None | MISSING) -> str:
if v is MISSING:
return "MISSING"
match v:
case None:
return "None"
case _:
assert_type(v, int)
return f"value={v}"
print([describe(v) for v in (None, MISSING, 42)])
実行結果と、両チェッカーの結果。
['None', 'MISSING', 'value=42']
$ mypy --python-version 3.15 s15_match_ok.py
Success: no issues found in 1 source file
$ pyright --pythonversion 3.15 s15_match_ok.py
0 errors, 0 warnings, 0 informations
どうしても case に書くなら、注釈は from markers import MISSING で裸の名前にし、case だけ markers.MISSING と書く。この組み合わせは両方とも通りました。ガード節 case _ if v is MISSING: も実行時には動く。ただ pyright はガードの結果を後続の case に持ち越さず、最後の case _: で v が int | MISSING のまま残りました。
3.15でdataclasses.MISSINGもsentinelになった
3.15.0 の Lib/ でテスト以外に sentinel() を呼んでいるのは dataclasses・functools・typing・urllib.parse の4モジュール。= object() の行はテストを除いてまだ 45行あり、PEP も全部の置き換えは求めていません。
Fieldのreprからアドレスが消えた
import sys, dataclasses
@dataclasses.dataclass
class User:
name: str
f = dataclasses.fields(User)[0]
print(sys.version.split()[0], repr(f.default), type(f.default).__name__)
print(hasattr(dataclasses, "_MISSING_TYPE"))
try:
print(isinstance(f.default, dataclasses._MISSING_TYPE))
except Exception as e:
print(type(e).__name__, e)
print(type(f.metadata).__name__)
3.13.15 の結果。
3.13.15 <dataclasses._MISSING_TYPE object at 0x10a075a90> _MISSING_TYPE
True
True
mappingproxy
3.15.0 の結果。
3.15.0 MISSING sentinel
False
AttributeError module 'dataclasses' has no attribute '_MISSING_TYPE'
frozendict
1行目のとおり、3.15 の repr(f.default) は MISSING。Field 全体の repr も default=MISSING になり、0x 付きのアドレスが消えました。4行目の metadata も mappingproxy から frozendict に変わっていて、こちらは Python frozendictの使い方—3.15組み込み版はdictを継承しない で書いた組み込み型 frozendict への置き換えです。
_MISSING_TYPEは消えた
出力の2行目と3行目が、移行で効く差分です。3.15 には dataclasses._MISSING_TYPE がもう無く、isinstance(x, dataclasses._MISSING_TYPE) は AttributeError で落ちる。変更は PR #149086 “Convert dataclasses.MISSING and dataclasses.KW_ONLY to sentinels”(gh-149083、2026-04-30 マージ)で入りました。
What’s New in Python 3.15 には、この変更が載っていない。dataclasses のリファレンスに “Changed in version 3.15: MISSING is now a sentinel object.” の1行があるだけです。_MISSING_TYPE は非公開名で、リファレンスも “No code should directly use the MISSING value.” と書いている。それでも GitHub のコード検索(2026-10-17)で dataclasses._MISSING_TYPE を探すと 1,848件がヒットし、snakemake や gluonts も含まれていました。
判定は field.default is dataclasses.MISSING に置き換える。3.13.15 と 3.15.0 の両方で True を返しました。
dataclasses-json 0.6.7のCatchAllが落ちる
実際に落ちるライブラリも1つ確かめました。dataclasses-json は PyPI の最新が 0.6.7(2024-06-09)。未知のキーを受け止める Undefined.INCLUDE と CatchAll の経路が、_MISSING_TYPE を参照しています。
from dataclasses import dataclass
from dataclasses_json import dataclass_json, Undefined, CatchAll
@dataclass_json(undefined=Undefined.INCLUDE)
@dataclass
class Event:
name: str
extra: CatchAll = None
print(Event.from_dict({"name": "deploy", "env": "prod"}))
3.15.0 の結果(末尾のみ)。
File "/private/tmp/sentinel-lab/.venv/lib/python3.15/site-packages/dataclasses_json/undefined.py", line 178, in _get_default
dataclasses._MISSING_TYPE)
^^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: module 'dataclasses' has no attribute '_MISSING_TYPE'
3.13.15 の結果。
Event(name='deploy', extra={'env': 'prod'})
参照箇所は dataclasses_json/undefined.py の178行目と182行目だけ。CatchAll を使わない from_dict と to_json は、3.15.0 でも動きました。修正 PR #578 “Only use dataclasses._MISSING_TYPE if it’s defined” は、2026-08-12 から open のまま。3.15 に上げる前に grep -rn _MISSING_TYPE .venv/lib/ を1回流しておけば、依存ライブラリの同じ落ち方を先に拾えます。
Python 3.14以前はtyping_extensions 4.16.0で書く
4.16.0で小文字のsentinelが入った
typing_extensions の CHANGELOG(4.16.0rc1)には “Rename typing_extensions.Sentinel to typing_extensions.sentinel” とあり、旧名の Sentinel は soft-deprecated の別名として残りました。3.13.15 と 4.16.0 の組み合わせで試した結果です。
import sys, copy, pickle
from typing_extensions import sentinel, Sentinel
print(sys.version.split()[0], sentinel is Sentinel)
MISSING = sentinel("MISSING")
print(repr(MISSING), type(MISSING))
print(copy.deepcopy(MISSING) is MISSING, pickle.loads(pickle.dumps(MISSING)) is MISSING)
print(bool(MISSING))
print(repr(int | MISSING))
実行結果。
3.13.15 True
MISSING <class 'typing_extensions.sentinel'>
True True
True
typing.Union[int, MISSING]
3.15 上では typing_extensions.sentinel is sentinel も True で、組み込みがそのまま返ります。from typing_extensions import sentinel と書いておけば、3.13 と 3.15 で同じコードが動きます。
4.15.0のSentinelはdeepcopyでTypeError
ロックファイルに 4.15.0 が残っていると、同じコードの挙動が変わります。
| 4.15.0 | 4.16.0 | |
|---|---|---|
| 使える名前 | Sentinel のみ | sentinel(Sentinel は別名) |
repr(MISSING) | <MISSING> | MISSING |
copy.copy / copy.deepcopy | TypeError: Cannot pickle 'Sentinel' object | 同一オブジェクト |
pickle.dumps | TypeError: Cannot pickle 'Sentinel' object | 名前で復元されて同一 |
4.15.0 では、番兵をデフォルト値に持つ dataclass を deepcopy した時点で TypeError になります。pyproject.toml の依存は typing_extensions>=4.16 で下限を切っておく。
変数名sentinelはruffのA001に掛かる
PEP 661 の “Backwards Compatibility” 節のとおり、既存の sentinel という変数はそのまま動きます。ただ同じモジュールで組み込みの sentinel() を呼ぶと、TypeError: 'object' object is not callable で落ちます。
sentinel = object()
def f(x=sentinel):
return x is sentinel
ruff 0.17.1 で flake8-builtins(A)を有効にした結果。
$ ruff check --target-version py315 --select A s20_ruff.py
A001 Variable `sentinel` is shadowing a Python builtin
--> s20_ruff.py:1:1
|
1 | sentinel = object()
| ^^^^^^^^
2 |
3 | def f(x=sentinel):
|
Found 1 error.
$ ruff check --target-version py314 --select A s20_ruff.py
All checks passed!
target-version が py315 のときだけ A001 が出ました。[tool.ruff] の target-version を py315 に上げた時点で、sentinel という名前の変数がまとめて見つかります。
まとめ
object()の番兵はcopy・deepcopy・pickleで別物になる。sentinel()は3つとも同一性を保つ- pickle できるのは、変数名と
nameが一致するモジュール直下の番兵。クラス属性の修飾名は実行時には動くが、pyright 1.1.414 がエラーにする case MISSING:は capture pattern で全部の値に一致し、mypy 2.4.0 も pyright 1.1.414 も警告を出さない。matchの前にif v is MISSING:で抜ける
- 3.15 で
dataclasses._MISSING_TYPEが消えた。dataclasses-json 0.6.7 のCatchAllはAttributeErrorで落ちる - 3.14 以前は
typing_extensions>=4.16のsentinelを使う。4.15.0 のSentinelは deepcopy でTypeError
3.15 に上げる前に、case に裸の番兵名が書かれていないかと、依存ライブラリに _MISSING_TYPE が残っていないかを確かめておきます。