Python sentinelとは—case MISSINGが全部に一致する理由

_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 に通した結果です。

書き方reprcopy / deepcopypickle型ヒント
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.04.16.0
使える名前Sentinel のみsentinel(Sentinel は別名)
repr(MISSING)<MISSING>MISSING
copy.copy / copy.deepcopyTypeError: Cannot pickle 'Sentinel' object同一オブジェクト
pickle.dumpsTypeError: 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 が残っていないかを確かめておきます。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次