Python's frozendict Is Finally Happening — And It's More Important Than You Think
Python 3.15 ships a built-in frozendict — hashable, O(1) lookups, order-preserving, thread-safe by design. Here's the full technical picture: the 14-year history, how hashing works, what it unlocks for lru_cache and free-threaded Python, and every sharp edge you need to know before you use it.
Python's had this asymmetry for years and most developers just accepted it as a law of nature. You want a constant string? Done. Constant integer? Obvious. Constant tuple? Yep. Constant frozenset? Got it. Constant dict? Sorry, no. You get types.MappingProxyType if you want a read-only view, except it's not hashable, you can't subclass it, and you can retrieve the underlying mutable dict through gc.get_referents() if you're sufficiently motivated to undo your own protection.
PEP 814, authored by Victor Stinner and Donghee Na, was accepted by the Python Steering Council on February 11, 2026. Python 3.15 ships a frozendict as a built-in. No import. No third-party package. It's in builtins alongside dict, frozenset, list, and everything else you take for granted.
This took fourteen years of failed proposals to land. Three separate PEPs. Two rejections. One deferral. The idea was good the whole time. The timing was wrong. Now it isn't.
The Fourteen-Year Road to Getting Here
Stinner first proposed this in 2012 as PEP 416. Same title: "Add a frozendict builtin type." The Steering Council rejected it. Raymond Hettinger argued that demand was low, that existing idioms handled the use cases, that multiple threads could "just agree by convention" not to mutate a shared dict. He was wrong about the demand. He was right that convention was what people were using, but that's not a defense of the status quo.
Then PEP 603 arrived in 2019. Yury Selivanov proposed collections.frozenmap instead, using a Hash Array Mapped Trie (HAMT) data structure. The HAMT gave you O(1) amortized mutation operations for producing modified copies, which is useful in functional programming patterns. The cost: O(log n) lookup instead of O(1). Also unordered. Also in collections rather than builtins. It was rejected too, though the HAMT code it would have exposed was already in CPython powering the contextvars module. The code existed. The type just wasn't public.
PEP 814 is different from both because the Python ecosystem moved in a specific direction between 2012 and 2025. Three things changed:
asynciolanded in 2014 and people started writing concurrent Python seriously.- Free threading shipped in Python 3.13 in 2024. The GIL is now optional.
concurrent.interpretersarrived in Python 3.14 in 2025, enabling true parallel subinterpreters.
Every one of these additions created demand for immutable shared state. When you remove the GIL, mutable shared state between threads becomes genuinely dangerous in ways it wasn't when the GIL serialized everything. An immutable dict you can pass across thread boundaries without a lock is no longer a nice-to-have. It's the safe option.
The Python community figured this out. Stinner and Na submitted PEP 814 in November 2025. The Steering Council accepted it February 11, 2026. Implementation PR is at github.com/python/cpython/pull/141508. The type lands before the Python 3.15 beta freeze on May 5, 2026.
What frozendict Is, Precisely
frozendict is a built-in immutable dictionary type. It is not a subclass of dict. It inherits directly from object. This design decision matters and was deliberate — if frozendict inherited from dict, you could call dict.__setitem__(frozendict_instance, key, value) and bypass immutability by going through the parent class. The PEP rejected inheritance for exactly this reason.
At the C level, the implementation adds PyFrozenDictObject, a struct that inherits from PyDictObject and adds one field: ma_hash. The hash gets computed on first call and cached in that field. Most of the dict implementation reuses verbatim. The cost of the type is minimal.
Constructing One
# Four construction patterns from PEP 814
# Empty frozendict
empty = frozendict()
# From keyword arguments
config = frozendict(host="localhost", port=8080, debug=False)
# From an existing dict
existing = {"theme": "dark", "lang": "en"}
fd = frozendict(existing)
# From an iterable of (key, value) pairs
from_pairs = frozendict([("a", 1), ("b", 2)])
# Combined: collection + kwargs
combined = frozendict({"host": "localhost"}, port=8080)
frozendict(host="localhost", port=8080, debug=False) builds the frozendict from the keyword arguments. Keys are the argument names as strings, values are the passed values. Identical to dict() construction.
frozendict(existing) performs a shallow copy of existing. The O(n) cost is the copy — each key-value pair gets copied from the source dict into the new frozendict's internal hash table. The original dict's memory layout is not modified.
frozendict([("a", 1), ("b", 2)]) iterates the sequence of tuples and inserts each pair. Same as calling dict() with the same argument.
Reading Works Exactly Like dict
config = frozendict(host="localhost", port=8080, debug=False)
# Standard key access
host = config["host"] # "localhost"
# .get() with default
port = config.get("port", 3000) # 8080
# Membership check
has_debug = "debug" in config # True
missing = "timeout" not in config # True
# Iteration
for key in config:
print(key) # host, port, debug (insertion order)
for key, value in config.items():
print(f"{key}: {value}")
# Length
n = len(config) # 3
config["host"] uses the same O(1) hash table lookup as a regular dict. The internal hash table is identical in structure to a standard Python dict hash table. There is no performance difference for reads.
config.get("port", 3000) returns config["port"] if the key exists, 3000 if it doesn't. No exception raised.
for key in config iterates in insertion order. frozendict preserves insertion order the same way dict has since Python 3.7.
Mutations Raise Immediately
config = frozendict(host="localhost", port=8080)
# Every mutation attempt raises TypeError
config["host"] = "0.0.0.0" # TypeError: 'frozendict' object does not support item assignment
config["timeout"] = 30 # TypeError: 'frozendict' object does not support item assignment
del config["port"] # TypeError: 'frozendict' object doesn't support item deletion
# Methods that mutate dict don't exist on frozendict
# config.pop("host") # AttributeError: 'frozendict' object has no attribute 'pop'
# config.clear() # AttributeError: 'frozendict' object has no attribute 'clear'
# config.update({"x": 1}) # AttributeError: 'frozendict' object has no attribute 'update'
# config.setdefault("x", 1) # AttributeError: 'frozendict' object has no attribute 'setdefault'
config["host"] = "0.0.0.0" raises TypeError immediately. The frozendict type overrides __setitem__ to raise rather than mutate. Same for __delitem__.
The mutation methods (pop, clear, update, setdefault, popitem) do not exist on frozendict at all. Calling them raises AttributeError, not TypeError. There is no method to call that could accidentally mutate the data.
Hashing: The Whole Point
This is the property that separates frozendict from dict and from types.MappingProxyType.
config = frozendict(host="localhost", port=8080)
# frozendict is hashable when all values are hashable
h = hash(config) # works, returns an integer
print(h) # something like -4782345923847234
# frozendict with unhashable value is not hashable
bad = frozendict(host="localhost", tags=["api", "web"])
hash(bad) # TypeError: unhashable type: 'list'
# Hash is order-independent
a = frozendict(x=1, y=2)
b = frozendict(y=2, x=1)
print(hash(a) == hash(b)) # True
print(a == b) # True
# Cross-type equality
print(frozendict(x=1, y=2) == dict(x=1, y=2)) # True
hash(config) computes the hash by hashing the frozenset of the frozendict's items. The pseudocode from PEP 814: hash(frozenset(frozendict.items())). This is why order doesn't affect the hash — frozensets are unordered, so shuffling the keys produces the same frozenset of items and thus the same hash.
hash(bad) raises TypeError because ["api", "web"] is a list, and lists are mutable and therefore unhashable. A frozendict is only hashable if all its values are hashable. This mirrors tuple: hash((1, 2)) works, hash((1, [])) raises.
The computed hash value gets cached in PyFrozenDictObject.ma_hash after the first call. Subsequent hash() calls return the cached value in O(1) without recomputing.
Using frozendict as a Dict Key
# frozendict can be a key in another dict
routing_table = {
frozendict(src="10.0.0.1", dst="10.0.0.2"): "route_A",
frozendict(src="10.0.0.3", dst="10.0.0.4"): "route_B",
}
query = frozendict(src="10.0.0.1", dst="10.0.0.2")
result = routing_table[query] # "route_A"
routing_table[query] works because frozendict is hashable. Python computes hash(query), finds the matching bucket in routing_table's hash table, and returns "route_A".
A regular dict cannot be a key because it's mutable — its contents (and therefore its hypothetical hash value) could change after insertion, breaking the hash table invariant.
Using frozendict in a Set
seen_configs = set()
cfg1 = frozendict(env="prod", version=3)
cfg2 = frozendict(env="staging", version=2)
cfg3 = frozendict(env="prod", version=3) # same as cfg1
seen_configs.add(cfg1)
seen_configs.add(cfg2)
seen_configs.add(cfg3) # duplicate — not added
print(len(seen_configs)) # 2, not 3
print(cfg1 in seen_configs) # True
seen_configs.add(cfg3) does not increase the set size because hash(cfg1) == hash(cfg3) and cfg1 == cfg3. Set deduplication works exactly as with any other hashable type.
The lru_cache Problem This Solves
functools.lru_cache caches function results keyed on function arguments. The arguments must be hashable because they become keys in an internal dict. Pass a dict, get a TypeError.
from functools import lru_cache
@lru_cache(maxsize=128)
def process_config(config):
# expensive computation based on config
return sum(config.values()) * len(config)
# This fails
process_config({"timeout": 30, "retries": 3})
# TypeError: unhashable type: 'dict'
Before frozendict, the workarounds were ugly. You'd convert to frozenset(d.items()) but lose key access inside the function and have to reconstruct. You'd convert to a JSON string but lose types and performance. You'd write a custom HDict subclass that added __hash__ but then had the hash-mutable-object problem.
With frozendict:
from functools import lru_cache
@lru_cache(maxsize=128)
def process_config(config: frozendict) -> int:
# config is immutable — safe to cache
return sum(config.values()) * len(config)
cfg = frozendict(timeout=30, retries=3)
result = process_config(cfg) # computed, cached
result2 = process_config(cfg) # returned from cache
result3 = process_config(frozendict(retries=3, timeout=30)) # cache hit — order-independent hash
print(result == result2 == result3) # True
process_config(cfg) on the first call runs the function body and caches the result. The cache key is (cfg,) — a tuple containing the frozendict argument. Since frozendict is hashable, this works cleanly.
process_config(frozendict(retries=3, timeout=30)) hits the same cache entry as process_config(frozendict(timeout=30, retries=3)) because the hash is order-independent. frozendict(timeout=30, retries=3) and frozendict(retries=3, timeout=30) have the same hash and compare equal.
This is a significant improvement over the frozenset(d.items()) workaround, which required you to call dict(config) inside the function to get key access back. With frozendict, the function receives a proper mapping and uses it directly.
Thread Safety and Free-Threaded Python
This is the use case that Hettinger dismissed in 2012 with "threads can just agree by convention." That argument had a shelf life. It expired in 2024 when Python 3.13 shipped the free-threaded build (no GIL).
The problem: in free-threaded Python, multiple threads execute Python bytecode in parallel. If two threads both read and write a shared dict, you have a data race. The GIL previously prevented this by only letting one thread execute Python at a time. With free threading, that protection is gone.
# This is a race condition in free-threaded Python
shared_cache = {}
def worker(key, value):
# Two threads doing this simultaneously can corrupt shared_cache
shared_cache[key] = value
# This is safe — frozendict is read-only by design
DEFAULTS = frozendict(
timeout=30,
retries=3,
encoding="utf-8",
)
def worker(request):
# Multiple threads can read DEFAULTS concurrently with no synchronization
timeout = DEFAULTS["timeout"]
# ...
DEFAULTS["timeout"] across multiple threads is safe with frozendict. From the PEP: "Once a frozendict is created, its shallow immutability is guaranteed. This means it can be safely shared between threads without synchronization."
The caveat is "shallow immutability." The frozendict itself cannot be mutated. But if its values are mutable objects, those objects can still be mutated from other threads.
# Shallow immutability — the frozendict is safe, its mutable values are not
shared = frozendict(config={"timeout": 30}) # value is a regular dict
# Thread A
shared["config"]["timeout"] = 60 # This works — mutating the VALUE, not the frozendict
# Thread B
print(shared["config"]["timeout"]) # might see 30 or 60 — race condition
shared["config"]["timeout"] = 60 succeeds because shared["config"] returns the inner dict, and you're then calling dict.__setitem__ on that inner dict. The frozendict protected its own keys and values references. It did not protect the objects those values point to.
If you need deep immutability, use frozensets and frozendicts all the way down.
The Union Operators: Non-Destructive Updates
frozendict supports | and |=, but they work differently from dict.
base = frozendict(host="localhost", port=8080)
override = frozendict(port=9000, debug=True)
# | creates a new frozendict — base and override unchanged
merged = base | override
print(merged) # frozendict({'host': 'localhost', 'port': 9000, 'debug': True})
print(base) # frozendict({'host': 'localhost', 'port': 8080}) — untouched
print(override) # frozendict({'port': 9000, 'debug': True}) — untouched
# |= rebinds the variable, does NOT modify in-place
d = frozendict(x=1)
original_id = id(d)
d |= frozendict(y=2)
print(d) # frozendict({'x': 1, 'y': 2})
print(id(d)) # DIFFERENT from original_id — d now points to a new object
base | override creates a new frozendict containing all keys from both. Right side wins on conflicts. Neither base nor override changes.
d |= frozendict(y=2) looks like an in-place update but produces a new frozendict and rebinds d to point at it. The object d previously pointed to is untouched. The id(d) changes, which confirms you're looking at a different object after the operation.
This is identical to how frozenset handles |=. It's mutation of the variable binding, not the object. This can surprise people who come from languages where |= always mutates in-place.
You can also merge a frozendict with a regular dict:
frozen = frozendict(x=1, y=2)
mutable = {"y": 99, "z": 3}
result = frozen | mutable # frozendict({'x': 1, 'y': 99, 'z': 3})
frozen | mutable produces a frozendict with right-side keys winning. Mixing types works cleanly.
Copy Semantics and the Shallow Copy Trap
import copy
original = frozendict(data=[1, 2, 3], name="test")
# .copy() returns the same object in CPython — O(1)
shallow = original.copy()
print(shallow is original) # True in CPython — same object
# Since both are immutable, sharing the object is safe
# The underlying hash table cannot be modified through either reference
# deep copy creates a new frozendict with deep-copied values
deep = copy.deepcopy(original)
print(deep is original) # False — different object
# The trap: mutable VALUES are still shared in shallow copy
with_list = frozendict(tags=["a", "b"])
shallow_copy = with_list.copy()
with_list["tags"].append("c") # Wait — this works? Yes. The LIST is mutable.
print(shallow_copy["tags"]) # ["a", "b", "c"] — shared reference, modified!
original.copy() in CPython returns the same frozendict object, not a copy. The rationale: the object is immutable, so returning a shared reference is safe and O(1). Two variables pointing to the same frozendict cannot interfere with each other because neither can mutate it.
with_list["tags"].append("c") succeeds because with_list["tags"] returns the list object ["a", "b"], and you're calling .append() on the list, not on the frozendict. The frozendict protected its key/value mapping. It did not freeze its values. The shallow copy shares the same list reference, so you see the mutation through both variables.
copy.deepcopy(original) creates a new frozendict where tags is a new list. Modifying the original list does not affect the deep copy.
frozendict vs. The Other Options
You had options before 3.15. They all had problems.
types.MappingProxyType
from types import MappingProxyType
d = {"x": 1}
proxy = MappingProxyType(d)
proxy["x"] # 1 — works
proxy["x"] = 2 # TypeError — can't mutate through proxy
hash(proxy) # TypeError: unhashable type: 'mappingproxy'
# The trap: the underlying dict can still be mutated
d["x"] = 99
print(proxy["x"]) # 99 — proxy reflects mutations to the original
hash(proxy) fails. It cannot be used as a dict key, in a set, or with lru_cache. MappingProxyType is a read-only view, not an immutable object. The underlying dict it wraps can still be modified. The proxy is transparent to mutations in the source.
collections.frozenmap (if it existed, via PEP 603)
O(log n) lookups instead of O(1). Unordered. Never shipped as a standard type — the PEP was rejected.
Third-party frozendict package on PyPI
Solid library. The PyPI frozendict package works well and predates PEP 814. But adding a package dependency for something this fundamental is friction. Now you don't need it for 3.15+.
dict subclass with custom __hash__
class HashableDict(dict):
def __hash__(self):
return hash(frozenset(self.items()))
Lies. This is a mutable dict that pretends to be hashable. If you add a key after computing the hash, the hash changes, corrupting any hash table that cached the old value. This is the "mutable object in set" bug that causes extremely confusing behavior at runtime.
frozendict from builtins is better than all of these. Hashable, O(1) lookups, immutable by design at the C level, no inheritance tricks.
The stdlib Is Getting Cleaned Up
PEP 814 includes a list of stdlib modules where the Python team will replace dict or MappingProxyType with frozendict for constants. The list is long. A sample:
dis.COMPILER_FLAG_NAMES— compiler flag name mapping, constanttoken.EXACT_TOKEN_TYPES— token type mapping, constantjson.decoder._CONSTANTS— JSON constant mapping, constantjson.encoder.ESCAPE_DCT— escape character mapping, constantssl._PROTOCOL_NAMES— TLS protocol name mapping, constantopcode._cache_format— opcode cache format, constanterrno.errorcode— errno → string mapping, constant
These are cases where the dict was intended to be read-only but was expressed as a mutable dict because there was no better option. Now there is.
builtins.eval() and exec() will also be updated to accept frozendict for the globals argument. This matters for sandbox-adjacent use cases where you want to pass a controlled namespace to user-supplied code.
What frozendict Does Not Do
A few things worth knowing before you go rewrite all your code.
It does not make values immutable.
fd = frozendict(items=[1, 2, 3])
fd["items"].append(4) # works — you modified the list, not the frozendict
frozendict is shallowly immutable. Values are not frozen. If you need deep immutability, you need all your values to also be immutable types.
There is no literal syntax.
# You write this
fd = frozendict(x=1, y=2)
# Not this (deferred to a possible future PEP)
fd = @{"x": 1, "y": 2} # does not exist in 3.15
The PEP deferred literal syntax to a future proposal. Various syntaxes were discussed (@{}, {||}, frozen{}), none reached consensus. For now, construction uses the same call syntax as dict.
There is no O(1) conversion from dict.
# This is O(n) — copies all items
fd = frozendict(existing_dict)
A .freeze() method that mutates a dict into a frozendict in O(1) was proposed and deferred. The concern: changing the type of an existing object is dangerous. Other references to the dict would suddenly find it frozen. Brett Cannon pointed out this creates "side-effects at a distance" — a thread holding a reference to the dict could try to mutate it and get a TypeError it never expected. The O(n) shallow copy avoids that problem at the cost of memory and time. A future PEP may address this with explicit copy-on-write semantics.
Practical Usage Patterns
# Pattern 1: Configuration objects that shouldn't drift
DEFAULT_SETTINGS = frozendict(
host="0.0.0.0",
port=8000,
workers=4,
timeout=30,
)
def start_server(settings: frozendict = DEFAULT_SETTINGS):
# settings is immutable — callers cannot accidentally corrupt DEFAULT_SETTINGS
# by passing a dict and then mutating it
print(f"Starting on {settings['host']}:{settings['port']}")
DEFAULT_SETTINGS as a module-level frozendict prevents the classic mutable default argument footgun. With a regular dict as default, a careless caller could mutate DEFAULT_SETTINGS and affect every subsequent call. frozendict makes that impossible.
# Pattern 2: Dict of dicts — lookup table with complex keys
from functools import lru_cache
PERMISSION_SETS = {
frozendict(role="admin", resource="users"): frozenset({"read", "write", "delete"}),
frozendict(role="viewer", resource="users"): frozenset({"read"}),
frozendict(role="admin", resource="billing"): frozenset({"read", "write"}),
}
def get_permissions(role: str, resource: str) -> frozenset:
key = frozendict(role=role, resource=resource)
return PERMISSION_SETS.get(key, frozenset())
perms = get_permissions("admin", "users") # frozenset({'read', 'write', 'delete'})
frozendict(role=role, resource=resource) as a lookup key is cleaner than a tuple (role, resource) when the semantics are "a collection of named properties." The frozendict key makes the lookup readable. Compare to PERMISSION_SETS.get((role, resource)) — the frozendict version tells you what the tuple elements mean.
# Pattern 3: lru_cache with configuration arguments
from functools import lru_cache
import re
@lru_cache(maxsize=256)
def compile_filter(filter_config: frozendict) -> re.Pattern:
"""Compile a regex pattern based on filter configuration.
Results are cached — same config always returns same compiled pattern.
"""
pattern = filter_config.get("pattern", ".*")
flags = re.IGNORECASE if filter_config.get("case_insensitive") else 0
return re.compile(pattern, flags)
# Call with different configs — each unique config compiles once and caches
p1 = compile_filter(frozendict(pattern=r"\d+", case_insensitive=True))
p2 = compile_filter(frozendict(pattern=r"\d+", case_insensitive=True)) # cache hit
p3 = compile_filter(frozendict(pattern=r"[a-z]+", case_insensitive=False))
print(p1 is p2) # True — same object from cache
compile_filter takes a frozendict as its argument. lru_cache can use it as a key because it's hashable. The function compiles the regex once per unique configuration and returns the cached re.Pattern on subsequent calls. This is the "expensive computation that depends on dict-shaped configuration" pattern that previously required ugly workarounds.
# Pattern 4: Thread-safe shared read-only state
import threading
SHARED_CONFIG = frozendict(
db_host="db.internal",
db_port=5432,
pool_size=10,
)
def database_worker():
# No lock needed — frozendict is immutable, safe for concurrent reads
host = SHARED_CONFIG["db_host"]
port = SHARED_CONFIG["db_port"]
# ... connect to database
pass
threads = [threading.Thread(target=database_worker) for _ in range(10)]
for t in threads:
t.start()
SHARED_CONFIG["db_host"] from ten concurrent threads needs no lock. The frozendict cannot be mutated. Its internal hash table is stable. No thread can modify it between another thread's hash computation and bucket lookup. This is the property the free-threaded Python GIL removal made urgent.
Coming to Python 3.15, May 2026
PEP 814 status is Final as of February 11, 2026. The CPython PR (python/cpython/pull/141508) is in progress. Beta freeze is May 5, 2026. The type ships in 3.15.
If you're on Python 3.14 or earlier and need frozendict now, the frozendict package on PyPI is the production-tested equivalent. Install it, use the same API, and swap out the import when 3.15 ships.
# Pre-3.15 compatibility shim
import sys
if sys.version_info >= (3, 15):
from builtins import frozendict
else:
from frozendict import frozendict # pip install frozendict
sys.version_info >= (3, 15) checks the Python version at runtime. If 3.15 or later, the built-in type is used. Earlier versions fall back to the PyPI package. Same API, same behavior, transparent swap when you upgrade.
The thing that took fourteen years wasn't the idea. The idea was fine in 2012. It was the ecosystem catching up — asyncio, free threading, independent subinterpreters, all of them creating the demand that Hettinger said was too low to justify the type. The demand was always there. Now it's undeniable and the language reflects that.