frozendict: the Immutable Mapping (PEP 814)
frozendict: the Immutable Mapping (PEP 814)
Thesis
For thirteen years Python had tuple for list and frozenset for set, but no built-in frozen partner for dict. Code made do with types.MappingProxyType, which is a read-only window onto a dict that someone else can still change, or with a third-party package. Python 3.15 adds frozendict as a builtin (PEP 814). It is an insertion-ordered, hashable, shallowly immutable mapping that is not a dict subclass. Use it for constants, defaults, cache keys and anything you hand to another thread. Remember the two things the name does not promise: the values inside can still be mutable, and isinstance(x, dict) is False.
Version note: frozendict is new in Python 3.15, and PEP 814 is Final. The rest of this part targets 3.14, so treat everything here as 3.15+: on 3.14 the name frozendict is a NameError. Every listing below was run on CPython 3.15.0rc3 (Linux x86-64) with uv run --python 3.15.0rc3, and the output is copied from those runs.
Mental model
dict frozendict MappingProxyType(d)
┌──────────┐ ┌──────────┐ ┌───────────┐ ┌──────────┐
│ entries │ │ entries │ (a copy) │ proxy │ ──▶ │ dict d │ ◀── anyone holding d
│ mutable │ │ fixed │ │ read-only │ │ mutable │ can still write
└──────────┘ │ ma_hash │ └───────────┘ └──────────┘
└──────────┘
unhashable hashable if every unhashable
key and value is
- Same engine, fewer doors. CPython builds
frozendictfrom the same compact hash table asdict(see the previous chapter), plus one cached hash field. Lookups cost the same asdictlookups, and iteration follows insertion order. - Construction copies.
frozendict(d)takes an O(n) shallow copy. Later changes toddo not show through.MappingProxyType(d)copies nothing, which is why changes do show through it. - Hash and equality ignore order.
frozendict(a=1, b=2) == frozendict(b=2, a=1), and their hashes match. PEP 814 giveshash(frozenset(self.items()))as the pseudo-code for the hash. Iteration does keep each instance’s own insertion order. - “Changing” it makes a new object.
|returns a new mapping.|=rebinds the name to a newfrozendictand leaves the old object (and anyone else holding it) untouched.copy()returns the object itself, because there is nothing to protect. - Not a dict. Its MRO is
(frozendict, object). It is acollections.abc.Mapping. The PEP rejected inheriting fromdictbecausedict.__setitem__(fd, k, v)would then mutate it.
dict |
frozendict |
MappingProxyType |
|
|---|---|---|---|
| Mutable through itself | yes | no | no |
| Mutable through someone else | — | no | yes (via the backing dict) |
| Hashable | no | yes, if all keys and values are | no |
isinstance(x, dict) |
True | False | False |
isinstance(x, Mapping) |
True | True | True |
Order-independent == |
yes | yes | yes |
Methods it lacks vs dict |
— | __setitem__, __delitem__, clear, pop, popitem, setdefault, update |
the same |
Worked examples
Case 1: The basic contract
# basics.py
limits = frozendict(cpu=2, memory_mib=512)
print(limits)
print(limits["cpu"], limits.get("disk", "unset"), len(limits))
print(list(limits.items()))
try:
limits["cpu"] = 4
except TypeError as e:
print("TypeError:", e)
try:
limits.update(cpu=4)
except AttributeError as e:
print("AttributeError:", e)
bigger = limits | {"cpu": 4} # a new frozendict
print(bigger, limits)
print(type(limits).__mro__)
print(isinstance(limits, dict))
import collections.abc
print(isinstance(limits, collections.abc.Mapping))uv run --python 3.15.0rc3 python basics.pyfrozendict({'cpu': 2, 'memory_mib': 512})
2 unset 2
[('cpu', 2), ('memory_mib', 512)]
TypeError: 'frozendict' object does not support item assignment
AttributeError: 'frozendict' object has no attribute 'update'
frozendict({'cpu': 4, 'memory_mib': 512}) frozendict({'cpu': 2, 'memory_mib': 512})
(<class 'frozendict'>, <class 'object'>)
False
True
Explanation. Every read-side operation behaves exactly like dict: indexing, get, len, and the items() view. Item assignment fails with a TypeError. The mutating methods simply do not exist, so they raise AttributeError rather than TypeError. That difference matters if you catch exceptions around “try to update”. limits | {...} builds a third mapping, and limits itself is unchanged. The last three lines are the type relationships that the trap section comes back to.
Case 2: Mappings as keys and cache arguments
The headline feature is the hash. A configuration, a set of query options or a feature-flag combination can now be a dict key, a set member, or an lru_cache argument without first being turned into a sorted tuple of pairs.
# keys.py
import functools
a = frozendict(region="eu", tier="gold")
b = frozendict(tier="gold", region="eu") # same items, other order
print(a == b, hash(a) == hash(b))
print(list(a), list(b)) # order is kept for iteration
print(a == {"region": "eu", "tier": "gold"}) # equal to a plain dict
prices = {a: 9.99}
print(prices[b]) # b finds a's entry
@functools.lru_cache
def quote(options):
print(" computing for", dict(options))
return 10 * len(options)
print(quote(frozendict(region="eu", tier="gold")))
print(quote(frozendict(tier="gold", region="eu"))) # cache hit
print(quote.cache_info())
try:
quote({"region": "eu"})
except TypeError as e:
print("TypeError:", e)True True
['region', 'tier'] ['tier', 'region']
True
9.99
computing for {'region': 'eu', 'tier': 'gold'}
20
20
CacheInfo(hits=1, misses=1, maxsize=128, currsize=1)
TypeError: unhashable type: 'dict'
Explanation. a and b were built in different orders. They compare equal and hash equal, so b finds the entry stored under a, and the second quote call is a cache hit (hits=1). Each instance still iterates in its own insertion order, so a frozendict used as a key never needs sorting just to be stable. A frozendict also compares equal to a plain dict with the same items. The last call shows the old failure mode that frozendict removes: a dict argument to a cached function is a TypeError at call time.
The hash is computed once and cached in the object. On this box hash(f) on a 1,000-item frozendict measured 27.6 ns per call with timeit, which is the cost of returning a cached field, not of hashing 1,000 pairs.
Case 3: A default argument that cannot be edited
The mutable-default bug is the oldest pitfall in the language. A frozendict default makes it impossible rather than merely discouraged.
# defaults.py
DEFAULT_HEADERS = frozendict({"accept": "application/json", "user-agent": "desk/1.0"})
def build_request(url, headers=DEFAULT_HEADERS):
merged = headers | {"host": url.split("/")[2]} # never mutates the default
return merged
r1 = build_request("https://api.example.com/v1/items")
r2 = build_request("https://cdn.example.com/x", headers=DEFAULT_HEADERS | {"accept": "*/*"})
print(r1)
print(r2)
print(DEFAULT_HEADERS) # unchanged
# the classic bug with a mutable default, for comparison
def build_request_buggy(url, headers={"accept": "application/json"}):
headers["host"] = url.split("/")[2]
return headers
build_request_buggy("https://api.example.com/v1")
print(build_request_buggy.__defaults__) # the default was editedfrozendict({'accept': 'application/json', 'user-agent': 'desk/1.0', 'host': 'api.example.com'})
frozendict({'accept': '*/*', 'user-agent': 'desk/1.0', 'host': 'cdn.example.com'})
frozendict({'accept': 'application/json', 'user-agent': 'desk/1.0'})
({'accept': 'application/json', 'host': 'api.example.com'},)
Explanation. With a frozendict default the function has to build a new mapping (headers | {...}), so the module-level constant stays exactly as written. The buggy version writes host into its own default, and that value leaks into every later call. __defaults__ shows the damage. The override pattern DEFAULT_HEADERS | {"accept": "*/*"} also reads well at call sites: start from the defaults and change one thing.
Case 4: Snapshot vs view (frozendict vs MappingProxyType)
Before 3.15 the standard “read-only dict” was MappingProxyType. It is still the right tool for some jobs, but it means something different.
# proxy_vs_frozen.py
from types import MappingProxyType
backing = {"debug": False}
view = MappingProxyType(backing) # read-only *view* of a dict
snap = frozendict(backing) # immutable *copy*
backing["debug"] = True # someone still holds the dict
print("proxy :", view["debug"])
print("frozen:", snap["debug"])
for name, m in [("proxy", view), ("frozen", snap)]:
try:
hash(m)
print(name, "is hashable")
except TypeError as e:
print(name, "TypeError:", e)proxy : True
frozen: False
proxy TypeError: unhashable type: 'dict'
frozen is hashable
Explanation. The proxy reflects the write to backing, because it never stopped being that dict. The frozendict took a copy at construction and still says False. The proxy’s hash error names 'dict' because it delegates hashing to the dict behind it. Use a proxy when you want readers to see live updates to state that you own and they don’t, which is how a class __dict__ is exposed. Use frozendict when you want a value that nobody can change. PEP 814 also points out that the dict behind a proxy can be recovered (for example with gc.get_referents()), so a proxy is a courtesy, not a guarantee.
Case 5: eval/exec globals, and a sharp edge
3.15 lets eval() and exec() take a frozendict as globals. The first attempt usually fails:
# sandbox_globals.py
import builtins
env = frozendict(x=41)
try:
eval("x + 1", env)
except TypeError as e:
print("TypeError:", e)
env = frozendict(x=41, __builtins__=builtins)
print(eval("x + 1", env))
try:
exec("y = 2", env)
except TypeError as e:
print("TypeError:", e)TypeError: cannot assign __builtins__ to frozendict globals
42
TypeError: 'frozendict' object does not support item assignment
Explanation. With a normal dict, eval quietly inserts __builtins__ into your globals if it is missing. With a frozendict it cannot, so you must provide __builtins__ yourself. After that, expressions can read the globals, and any statement that would bind a global fails. This is a useful guard for configuration expressions. It is not a sandbox. PEP 814 explicitly drops the sandboxing motivation of the rejected PEP 416, and the evaluated code still has all of builtins.
The trap
frozendict is frozen one level deep, and it is not a dict. Both facts break real code during migration.
# trap.py
import json
cfg = frozendict(name="ingest", tags=["nightly"])
# 1. Frozen is shallow: the list inside is still a list.
cfg["tags"].append("urgent")
print(cfg)
try:
hash(cfg)
except TypeError as e:
print("TypeError:", e)
# 2. It is not a dict, so isinstance(x, dict) checks reject it.
def validate(config):
if not isinstance(config, dict):
raise TypeError(f"config must be a dict, got {type(config).__name__}")
return "ok"
try:
validate(cfg)
except TypeError as e:
print("TypeError:", e)
# 3. The left operand decides the result type of |.
print(type({} | cfg).__name__, type(cfg | {}).__name__)
# 4. |= rebinds the name; it does not mutate anything.
alias = cfg
cfg |= {"name": "ingest-v2"}
print(alias["name"], cfg["name"], alias is cfg)
# 5. json writes it like a dict and reads it back as a dict.
text = json.dumps(cfg)
print(text, type(json.loads(text)).__name__)frozendict({'name': 'ingest', 'tags': ['nightly', 'urgent']})
TypeError: unhashable type: 'list'
TypeError: config must be a dict, got frozendict
dict frozendict
ingest ingest-v2 False
{"name": "ingest-v2", "tags": ["nightly", "urgent"]} dict
What went wrong, line by line:
- Shallow immutability. The “frozen” config gained a tag, because the list it holds is an ordinary list. The same list makes the whole mapping unhashable, and you only find out when something tries to hash it, which may be far from where it was built. Constructing a
frozendictwith mutable values is legal. Hashing it is not. isinstance(x, dict). Validators, serializers and framework hooks written before 3.15 often check fordictexplicitly. In C extensions the check isPyDict_Check(), which is false for afrozendict. The fix in your own code isisinstance(x, collections.abc.Mapping). In someone else’s code it isdict(x)at the boundary.dict | frozendictis adict. The left operand’s type wins. A “frozen” merge written asoverrides | DEFAULTShands back something mutable.|=rebinds. This is good news, becausealiaskept the old value. It does surprise readers who expect|=to mean in-place, as it does fordict.- Round-trips lose the type.
json, and anything else that serialises “a mapping”, writes afrozendictlike a dict and reads back adict.pickleandcopydo preservefrozendict.
The boring rule
Freeze at the boundary, freeze deeply, and check for Mapping, not dict.
When data arrives (parsed config, decoded JSON, a request’s options), convert it once into a deeply immutable value. After that, pass it around freely, use it as a key, and share it across threads without locks:
# freeze.py
import json
from collections.abc import Mapping
def freeze(value):
"""Return a deeply immutable, hashable copy of JSON-like data."""
if isinstance(value, Mapping):
return frozendict((k, freeze(v)) for k, v in value.items())
if isinstance(value, (list, tuple)):
return tuple(freeze(v) for v in value)
if isinstance(value, set):
return frozenset(freeze(v) for v in value)
return value
raw = '{"name": "ingest", "tags": ["nightly"], "limits": {"cpu": 2}}'
cfg = freeze(json.loads(raw))
print(cfg)
print(type(cfg["limits"]).__name__, type(cfg["tags"]).__name__)
print(hash(cfg) == hash(freeze(json.loads(raw))))
def validate(config):
if not isinstance(config, Mapping): # accepts dict and frozendict
raise TypeError("config must be a mapping")
return "ok"
print(validate(cfg), validate(json.loads(raw)))frozendict({'name': 'ingest', 'tags': ('nightly',), 'limits': frozendict({'cpu': 2})})
frozendict tuple
True
ok ok
Supporting rules:
- Use
frozendictfor module constants and default arguments. That covers lookup tables, default headers and status maps. It costs nothing at read time. - Don’t freeze in a hot loop.
frozendict(d)is an O(n) copy. On this box, copying a 1,000-item dict took 3.87 µs asfrozendict(d)and 3.71 µs asdict(d), so the cost is the same asdict(d)and is paid each time. Freeze once and reuse.fd.copy()is free, because it returnsfditself. - Keep
MappingProxyTypefor exposing a live view of state you still mutate. - Annotate as
frozendict[str, int]when the immutability is part of the contract. Annotate asMapping[str, int]when you only need read access and want to accept both. - If you support 3.14 and 3.15, guard the name, for example with
try: frozendict/except NameError:falling back toMappingProxyTypeor a tuple of items. Don’t ship a package that only sometimes returns a hashable object without documenting it.
Try this
- Build two
frozendicts with the same items in different orders and put both in aset. How many elements does the set have? Now comparelist(a.items()) == list(b.items())and explain the difference froma == b. - Write
deep_hashable(x)that returnsTrueonly ifhash(x)would succeed, without callinghashon the top-level object. Test it onfrozendict(tags=["x"])and onfreeze(...)output. - Subclass
frozendictto add awith_(**changes)method that returnsself | changes. What type doeswith_return, your subclass orfrozendict? Find out by running it, then fix it if needed. - Take a project that uses
isinstance(x, dict)and grep for how many of those checks would reject afrozendict. Which ones should becomeMapping, and which genuinely need mutation? - Time
hash(fd)the first time and the second time for a 100,000-itemfrozendict(usetime.perf_counter_ns()around single calls). What does the gap tell you about where the hash is stored?
Sources
- PEP 814 — Add
frozendictbuilt-in type (Final, Python 3.15): https://peps.python.org/pep-0814/ - Built-in types —
frozendict(3.15 documentation): https://docs.python.org/3.15/builtins/stdtypes.html#frozendict - What’s New in Python 3.15 —
frozendict: https://docs.python.org/3.15/whatsnew/3.15.html#whatsnew315-frozendict - PEP 416 — Add a
frozendictbuiltin type (Rejected, 2012): https://peps.python.org/pep-0416/ - PEP 603 — Adding a
frozenmaptype tocollections(for the persistent-map alternative): https://peps.python.org/pep-0603/ types.MappingProxyType: https://docs.python.org/3.15/library/types.html#types.MappingProxyType