Python 3.15.0rc2 で次の3行を実行すると、1行目は False になります。
fd = frozendict(host="db", port=5432)
print(isinstance(fd, dict))
print(type(fd | {"debug": True}))
print(type({"debug": True} | fd))
実行結果。
False
<class 'frozendict'>
<class 'dict'>
3.15 で組み込み型になった frozendict は dict を継承していません。| の結果の型も、左右どちらに置くかで変わります。
確認環境は Python 3.15.0rc2(macOS arm64)と 3.14.6、型チェッカーは pyright 1.1.414 と mypy 2.3.1。
frozendictはimportなしで使える組み込み型
公式ドキュメントでは、Built-in Types の “Mapping types — dict, frozendict” 節に “Frozen dictionaries” として載っています。サジェストに出る「python import frozendict」「python collections frozendict」は、どちらも不要。collections にも typing にも frozendict という属性は無く、名前があるのは builtins だけです。3.15.0 の正式版は 2026年10月1日 の予定で、rc2 から先はバグ修正しか入りません。
コンストラクタは3通り、リテラル構文は無い
キーワード引数、マッピング、キーと値のタプル列の3つから作れます。
a = frozendict(x=1, y=2)
b = frozendict({"y": 2, "x": 1})
c = frozendict([("x", 1), ("y", 2)])
print(a)
print(list(a), list(b))
print(a == b == c, hash(a) == hash(b))
print(a == {"x": 1, "y": 2})
print(sorted(set(dir(dict)) - set(dir(frozendict))))
try:
a["z"] = 3
except TypeError as e:
print(e)
実行結果。
frozendict({'x': 1, 'y': 2})
['x', 'y'] ['y', 'x']
True True
True
['__delitem__', '__ior__', '__setitem__', 'clear', 'pop', 'popitem', 'setdefault', 'update']
'frozendict' object does not support item assignment
{} に相当するリテラルはありません。PEP 814 は “Deferred Ideas” の “New syntax for frozendict literals” で、構文の追加を先送りにしました。
dictにあってfrozendictに無いメソッド
出力の5行目が dict との差分です。書き換え系の __setitem__ や update に加えて、インプレース更新の __ior__ も無い。get、items、fromkeys などの読み取り系は、dict と同じ名前で使えます。
挿入順は残るが、比較とhashは順序を見ない
list(a) と list(b) はキーの並びが違います。それでも a == b と hash(a) == hash(b) はどちらも True。PEP 814 の “Hashing and Comparison” 節が、ハッシュ値を hash(frozenset(frozendict.items())) 相当と定義しているためです。中身が同じ dict との比較も True を返します。
hashableになる条件と、2回目のhashが速い理由
import time
fd = frozendict({i: i for i in range(1_000_000)})
for n in range(1, 4):
t = time.perf_counter()
hash(fd)
print(f"{n}回目: {(time.perf_counter() - t) * 1000:.4f} ms")
try:
hash(frozendict(tags=["a", "b"]))
except TypeError as e:
print(e)
100万件の frozendict に hash() を3回かけた結果です。
1回目: 3.2893 ms
2回目: 0.0008 ms
3回目: 0.0002 ms
unhashable type: 'list'
値にlistが入るとhashでTypeError
キーだけでなく、値もすべてhashableでないと hash() は通りません。しかも作る時点ではエラーにならない。frozendict(tags=["a", "b"]) は普通に作れて、ハッシュを取った瞬間に落ちます。tuple と同じ「浅い不変」です。
2回目以降はma_hashに残した値を返す
1回目の 3.29ms に対して、2回目は 0.0008ms。CPython 3.15 の Include/internal/pycore_dict.h を開くと、dict の構造体に ma_hash を1つ足しただけの定義でした。CPython は1回目に計算したハッシュ値をここに書き込み、2回目からはそれを返します。
typedef struct {
PyDictObject ob_base;
Py_hash_t ma_hash;
} PyFrozenDictObject;
このフィールドの分だけ、同じ中身の dict より大きくなります。
import sys
d = {f"k{i}": i for i in range(1000)}
print(sys.getsizeof(d), sys.getsizeof(frozendict(d)))
実行結果。
26032 26040
差は 8バイト。64bit 環境の Py_hash_t 1つ分と一致します。
lru_cacheの引数にそのまま渡せる
functools.lru_cache は引数をキャッシュのキーにするので、dict を渡すと落ちます。Pythonのlru_cacheとcache—メモ化の使い分けとメソッドの落とし穴の「dict/listを渡すとTypeError」で書いた件。3.15 なら frozendict で包むだけで通ります。
from functools import lru_cache
@lru_cache
def build_dsn(opts):
return ";".join(f"{k}={v}" for k, v in opts.items())
try:
build_dsn({"host": "db", "port": 5432})
except TypeError as e:
print("dict:", e)
print(build_dsn(frozendict(host="db", port=5432)))
print(build_dsn(frozendict(port=5432, host="db")))
print(build_dsn.cache_info())
実行結果。
dict: unhashable type: 'dict'
host=db;port=5432
host=db;port=5432
CacheInfo(hits=1, misses=1, maxsize=128, currsize=1)
キーの順番を入れ替えた2回目の呼び出しが hits=1 で返っています。ハッシュが順序を見ないので、呼び出し側の書き順でキャッシュが分かれることはありません。
MappingProxyTypeとの違い
Python で辞書をイミュータブルに扱う手段は、3.14 まで types.MappingProxyType が定番でした。PEP 814 の “Relationship to PEP 416 frozendict” 節は、この型をこう評しています。
This type is not hashable and it’s not possible to inherit from it. It’s also easy to retrieve the original dictionary which can be mutated, for example using gc.get_referents().
変換コストはO(n)のコピー
timeit で1回あたりの時間を測りました。10件と1,000件は20,000回、100,000件は200回の平均です。
| 件数 | dict(d) | frozendict(d) | MappingProxyType(d) | frozendict(fd) |
|---|---|---|---|---|
| 10 | 0.041µs | 0.040µs | 0.029µs | 0.014µs |
| 1,000 | 1.211µs | 1.289µs | 0.031µs | 0.014µs |
| 100,000 | 179.797µs | 176.570µs | 0.039µs | 0.018µs |
frozendict(d) は dict(d) と同じだけかかる、丸ごとのコピーです。dict.freeze() のような O(1) の変換は、PEP の “Method to convert dict to frozendict” が先送りにしています。理由は “can be added later if needed”。frozendict(fd) は fd 自身を返すので、何度包んでもコピーは起きません。1,000件から1キー引く参照は dict 13.5ns / frozendict 13.3ns / MappingProxyType 16.6ns。frozendict は dict と同じ速さで、MappingProxyType だけ約23%遅い結果でした。
元のdictを握られると書き換えられる
import gc
import types
src = {"mode": "readonly"}
mp = types.MappingProxyType(src)
src["mode"] = "write"
print(mp["mode"])
gc.get_referents(mp)[0]["mode"] = "admin"
print(mp["mode"])
try:
hash(mp)
except TypeError as e:
print(e)
実行結果。
write
admin
unhashable type: 'dict'
MappingProxyType は元の dict への参照を持つだけで、中身をコピーしません。frozendict は作るときにコピーするので、後から src を変えても値は readonly のまま。gc.get_referents() に渡しても、返るのは ['readonly'] という値だけでした。
dict判定が外れる問題とdictへの戻し方
公式ドキュメントの “Frozen dictionaries” には “frozendict is not a dict subclass but inherits directly from object.” とあります。PEP 814 の “Rejected Ideas” によれば、継承すると dict.__setitem__(frozendict, key, value) で中身を書き換えられてしまう。その結果、既存コードの isinstance(obj, dict) は frozendict を弾きます。
isinstance(obj, dict) の分岐を素通りする
ログに出す前にパスワードを伏せる関数で試しました。dict と list を再帰でたどる、よくある書き方です。
def redact(obj):
if isinstance(obj, dict):
return {k: "***" if k == "password" else redact(v) for k, v in obj.items()}
if isinstance(obj, list):
return [redact(v) for v in obj]
return obj
print(redact({"user": "app", "password": "s3cret"}))
print(redact(frozendict(user="app", password="s3cret")))
実行結果。
{'user': 'app', 'password': '***'}
frozendict({'user': 'app', 'password': 's3cret'})
2行目で s3cret がそのまま出ています。frozendict は dict の分岐に入らず、最後の return obj まで素通り。例外も出ません。json.dumps() と pprint も 3.15 で frozendict に対応済み。ログに書き出す段階でも止まらず、平文のまま出力します。
collections.abc.Mapping で受ける
What’s New の “PEP 814: Add frozendict built-in type” 節は、isinstance(arg, (dict, frozendict)) への書き換えを案内しています。isinstance(arg, collections.abc.Mapping) でもよい、とも書いてあります。Mapping 側なら MappingProxyType も拾えるうえ、3.14 でも NameError にならない。
from collections.abc import Mapping
def redact(obj):
if isinstance(obj, Mapping):
return {k: "***" if k == "password" else redact(v) for k, v in obj.items()}
if isinstance(obj, list):
return [redact(v) for v in obj]
return obj
print(redact(frozendict(user="app", password="s3cret")))
実行結果。
{'user': 'app', 'password': '***'}
書き換え候補は grep で洗えます。type(x) is dict の形も同じ理由で外れるので、一緒に拾います。
grep -rnE 'isinstance\(.*\bdict\b|type\(.*\) is dict' --include='*.py' .
この記事の検証用ディレクトリで実行した結果。
01_intro.py:3:print(isinstance(fd, dict))
07_redact_ng.py:2: if isinstance(obj, dict):
dict | fd は dict、fd | dict は frozendict
base = frozendict(timeout=30)
print(type(base | {"retries": 3}).__name__)
print(type({"retries": 3} | base).__name__)
print(type({**base}).__name__, type(dict(base)).__name__)
cfg = base
cfg |= {"retries": 3}
print(cfg, base, cfg is base)
class Settings(frozendict):
pass
s = Settings(timeout=30)
print(type(s | {"retries": 3}).__name__, type(s.copy()).__name__)
実行結果。
frozendict
dict
dict dict
frozendict({'timeout': 30, 'retries': 3}) frozendict({'timeout': 30}) False
frozendict frozendict
| の結果の型は左辺で決まります。サジェストの「frozendict to dict」は、{**base} か dict(base) で済みます。|= は __ior__ が無いため、新しい frozendict を作って変数を付け替えるだけ。base は元のままです。
サブクラスの型は保たれません。Settings に | や copy() を使うと、戻り値は素の frozendict。Settings に独自メソッドを足していても、| の結果からは呼べなくなります。
型ヒントの書き方とpyrightの誤検出
型注釈は dict と同じ形で frozendict[str, int] と書きます。typing.FrozenDict のような別名は用意されていません。
pyright 1.1.414は引数付きの呼び出しをエラーにする
同じファイルを pyright と mypy にかけました。
DEFAULTS: frozendict[str, int] = frozendict(timeout=30, retries=3)
FROM_DICT = frozendict({"timeout": 30})
def mutate(cfg: frozendict[str, int]) -> None:
cfg["timeout"] = 10
reveal_type(DEFAULTS | {"x": 1})
reveal_type({"x": 1} | DEFAULTS)
pyright --pythonversion 3.15 settings.py の結果。
/private/tmp/fdtype2/settings.py
/private/tmp/fdtype2/settings.py:1:45 - error: No parameter named "timeout" (reportCallIssue)
/private/tmp/fdtype2/settings.py:1:57 - error: No parameter named "retries" (reportCallIssue)
/private/tmp/fdtype2/settings.py:2:24 - error: Expected 0 positional arguments (reportCallIssue)
/private/tmp/fdtype2/settings.py:6:5 - error: "__setitem__" method not defined on type "frozendict[str, int]" (reportIndexIssue)
/private/tmp/fdtype2/settings.py:9:13 - information: Type of "DEFAULTS | { "x": 1 }" is "frozendict[str, int]"
/private/tmp/fdtype2/settings.py:10:13 - information: Type of "{ "x": 1 } | DEFAULTS" is "dict[str, int]"
4 errors, 0 warnings, 2 informations
mypy --python-version 3.15 settings.py の結果。
settings.py:6: error: Unsupported target for indexed assignment ("frozendict[str, int]") [index]
settings.py:9: note: Revealed type is "frozendict[str, int]"
settings.py:10: note: Revealed type is "dict[str, int]"
Found 1 error in 1 file (checked 1 source file)
6行目の代入は、両者とも正しく止めています。食い違うのは1〜2行目。実行時には通るコンストラクタ呼び出しを、pyright だけがエラーにしました。| の戻り値の型は、どちらも実行結果と一致しています。
同梱typeshedに残った引数なしの__init__
pip 版 pyright のキャッシュから、同梱スタブの該当行を抜き出しました。
cd ~/.cache/pyright-python/1.1.414/node_modules/pyright/dist/typeshed-fallback/stdlib
sed -n '1372,1374p;1392p' builtins.pyi
実行結果。
if sys.version_info >= (3, 15):
@disjoint_base
class frozendict(Mapping[_KT, _VT]):
def __init__(self) -> None: ...
frozendict クラスに、引数を取らない __init__ が残っています。typeshed には issue #15985 で報告があり、PR #15989 が 2026年7月8日 にこの行を消しました。PR の説明文は “type checkers validate a constructor call against both __new__ and __init__”。__new__ のオーバーロードが正しくても、型チェッカーは __init__ 側で引数過多と判定します。mypy 2.3.1 の同梱スタブにこの __init__ は無く、修正後の定義でした。
pyright が修正を取り込むまでは、行単位で抑止します。型推論はそのまま効きます。
DEFAULTS = frozendict(timeout=30, retries=3) # pyright: ignore[reportCallIssue]
reveal_type(DEFAULTS)
実行結果。
/private/tmp/fdtype/ignore.py
/private/tmp/fdtype/ignore.py:2:13 - information: Type of "DEFAULTS" is "frozendict[str, int]"
0 errors, 0 warnings, 1 information
jsonとdataclassesで変わった挙動
object_hookとarray_hookでJSONを丸ごと凍らせる
3.15 の json.loads() には array_hook 引数が増えました。json のドキュメントには “Changed in version 3.15: Added support for array_hook.” と注記があります。
import json
s = '{"db": {"host": "x", "ports": [5432, 5433]}, "debug": false}'
cfg = json.loads(s, object_hook=frozendict)
try:
hash(cfg)
except TypeError as e:
print("object_hookだけ:", e)
cfg = json.loads(s, object_hook=frozendict, array_hook=tuple)
print(cfg)
print(isinstance(hash(cfg), int))
実行結果。
object_hookだけ: unhashable type: 'list'
frozendict({'db': frozendict({'host': 'x', 'ports': (5432, 5433)}), 'debug': False})
True
object_hook だけでは ports が list のまま残り、ハッシュで落ちます。読み込んだ設定を lru_cache のキーにするなら、両方の指定が要ります。
What’s Newの例よりobject_hookが速かった
What’s New の json 節は “Passing combined frozendict to object_pairs_hook param and tuple to array_hook” と、object_pairs_hook 側を例に出しています。20キーのオブジェクト5,000個、1,565,000バイトのJSONを読み、10回×7セットの中央値を取りました。
| 指定 | 1回あたり | 既定との差 |
|---|---|---|
| 既定(dict) | 9.02ms | – |
| object_hook=frozendict | 9.26ms | +2.7% |
| object_pairs_hook=frozendict | 9.74ms | +8.0% |
| object_hook + array_hook=tuple | 9.63ms | +6.8% |
| object_pairs_hook + array_hook=tuple | 10.28ms | +14.0% |
計測を2回やり直しても、object_hook が object_pairs_hook より速い順位は変わりませんでした。object_pairs_hook が受け取るのは dict ではなく “an ordered list of pairs”。重複キーを検出したいなど、ペアの並びそのものが要る場面でなければ object_hook で足ります。
field()の既定metadataがfrozendictになった
import dataclasses
import types
f1 = dataclasses.field(default=1)
f2 = dataclasses.field(default=1, metadata={"unit": "sec"})
print(type(f1.metadata).__name__, type(f2.metadata).__name__)
print(isinstance(f1.metadata, types.MappingProxyType))
実行結果。
frozendict mappingproxy
False
metadata を渡さないと frozendict、渡すと mappingproxy。dataclasses のドキュメントは今も “This value is wrapped in MappingProxyType()” のままで、rc2 の実装とずれています。Victor Stinner 氏のブログが “Use frozendict in the Standard Library” 節でこの変更に触れていて、意図した挙動です。原文は “Field.metadata becomes an empty frozendict if there is no metadata.”。MappingProxyType で型を判定するライブラリは、metadata の無いフィールドでだけ判定が外れる。field() の引数全般はPython dataclass実践—field・frozen・asdictの正しい書き方にまとめました。
3.14以前との互換とPyPIのfrozendict
3.14.6 で frozendict(a=1) を実行すると、NameError: name 'frozendict' is not defined. Did you mean: 'frozenset'? で止まります。3.14 以前でも同じ名前を使うなら、PyPI の frozendict パッケージ(2.4.7)が候補です。
from frozendict import frozendict は組み込みを隠す
import builtins
from frozendict import frozendict
x = frozendict(a=1)
print(frozendict is builtins.frozendict)
print(isinstance(x, builtins.frozendict), isinstance(x, dict))
print(x == builtins.frozendict(a=1))
3.15.0rc2 に PyPI 版を入れて実行した結果。
False
False True
True
PyPI 版は dict のサブクラスです。3.15 に上げてもこの import が残っていると、組み込み版は隠れたまま。組み込み版との == は True なのに、isinstance(x, builtins.frozendict) は False を返します。
sys.version_infoで切り替えるとdict判定が逆転する
バージョンで実装を切り替えるコードに、さきほどの isinstance(obj, dict) 版 redact() を組み合わせました。
import sys
if sys.version_info >= (3, 15):
FrozenDict = frozendict
else:
from frozendict import frozendict as FrozenDict
def redact(obj):
if isinstance(obj, dict):
return {k: "***" if k == "password" else redact(v) for k, v in obj.items()}
return obj
print(sys.version.split()[0], redact(FrozenDict(user="app", password="s3cret")))
3.14.6 と 3.15.0rc2 の両方で実行した結果。
3.14.6 {'user': 'app', 'password': '***'}
3.15.0rc2 frozendict({'user': 'app', 'password': 's3cret'})
3.14 の CI では伏せ字が効いてテストも通ります。3.15 に上げた途端、同じコードが素通りする。互換レイヤーを入れる前に dict 判定を Mapping に書き換えておけば、この逆転は起きません。
まとめ
frozendictは 3.15 の組み込み型で import 不要。dictを継承しないのでisinstance(x, dict)はFalse- 値まですべて hashable なら
hash()が通り、CPython が結果をma_hashに保存する。lru_cacheの引数にそのまま渡せる frozendict(d)は O(n) のコピーで、元のdictを変えても影響しない。MappingProxyTypeは O(1) だが中身を守れない- pyright 1.1.414 は引数付きのコンストラクタを誤ってエラーにする。原因は同梱 typeshed の古い
__init__で、mypy 2.3.1 は通る
移行で真っ先に見るのは isinstance(..., dict) と type(x) is dict の分岐です。collections.abc.Mapping に寄せておけば、3.14 と 3.15 で結果が割れません。