frozendict: the Immutable Mapping (PEP 814)

Authors

K19G

grok-bot

Updated

October 31, 2026

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 frozendict from the same compact hash table as dict (see the previous chapter), plus one cached hash field. Lookups cost the same as dict lookups, and iteration follows insertion order.
  • Construction copies. frozendict(d) takes an O(n) shallow copy. Later changes to d do 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 gives hash(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 new frozendict and 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 a collections.abc.Mapping. The PEP rejected inheriting from dict because dict.__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.py
frozendict({'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 edited
frozendict({'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:

  1. 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 frozendict with mutable values is legal. Hashing it is not.
  2. isinstance(x, dict). Validators, serializers and framework hooks written before 3.15 often check for dict explicitly. In C extensions the check is PyDict_Check(), which is false for a frozendict. The fix in your own code is isinstance(x, collections.abc.Mapping). In someone else’s code it is dict(x) at the boundary.
  3. dict | frozendict is a dict. The left operand’s type wins. A “frozen” merge written as overrides | DEFAULTS hands back something mutable.
  4. |= rebinds. This is good news, because alias kept the old value. It does surprise readers who expect |= to mean in-place, as it does for dict.
  5. Round-trips lose the type. json, and anything else that serialises “a mapping”, writes a frozendict like a dict and reads back a dict. pickle and copy do preserve frozendict.

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 frozendict for 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 as frozendict(d) and 3.71 µs as dict(d), so the cost is the same as dict(d) and is paid each time. Freeze once and reuse. fd.copy() is free, because it returns fd itself.
  • Keep MappingProxyType for exposing a live view of state you still mutate.
  • Annotate as frozendict[str, int] when the immutability is part of the contract. Annotate as Mapping[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 to MappingProxyType or a tuple of items. Don’t ship a package that only sometimes returns a hashable object without documenting it.

Try this

  1. Build two frozendicts with the same items in different orders and put both in a set. How many elements does the set have? Now compare list(a.items()) == list(b.items()) and explain the difference from a == b.
  2. Write deep_hashable(x) that returns True only if hash(x) would succeed, without calling hash on the top-level object. Test it on frozendict(tags=["x"]) and on freeze(...) output.
  3. Subclass frozendict to add a with_(**changes) method that returns self | changes. What type does with_ return, your subclass or frozendict? Find out by running it, then fix it if needed.
  4. Take a project that uses isinstance(x, dict) and grep for how many of those checks would reject a frozendict. Which ones should become Mapping, and which genuinely need mutation?
  5. Time hash(fd) the first time and the second time for a 100,000-item frozendict (use time.perf_counter_ns() around single calls). What does the gap tell you about where the hash is stored?

Sources