How to Use frozendict in Python 3.15 to Freeze Config and Cache Dictionary Arguments
Learn the new built-in frozendict in Python 3.15 step by step: freeze shared settings, use mappings as cache keys and set members, load JSON and TOML as deeply immutable data, and avoid the traps...
Many Python programs have a dictionary that nobody should change after it is built: the settings loaded at startup, a table of prices, the defaults every request starts from. Python has long had immutable lists (tuples) and immutable sets (frozensets), but no immutable dictionary. Python 3.15 adds one: a new built-in type called frozendict, described in PEP 814. It behaves like a dict that refuses every change after it is created, and because it cannot change, it can be hashed as long as its contents can be hashed too.
Table Of Content
- Prerequisites
- Create the project folder
- Check that your Python has frozendict
- Check it worked
- Step 1: Create a frozendict and read from it
- What just happened
- Check it worked
- Step 2: Prove it is read-only and fix the shared-defaults bug
- Writes are refused
- The shared-defaults bug with a plain dict
- The same code with a frozendict
- The fix: build a new mapping with the | operator
- What |= really does
- Check it worked
- Step 3: Compare, hash and deduplicate
- Equality and hashing ignore order
- Deduplicate requests with a set
- Why not just use MappingProxyType?
- Check it worked
- Step 4: Cache a function that takes a mapping
- A dict argument cannot be cached
- A frozendict argument can
- A cache hands every caller the same object
- Check it worked
- Step 5: Freeze every level, not just the top
- Freezing only the top level
- Freezing every level while parsing
- Why object_hook alone is not enough
- Writing it back out
- Check it worked
- Step 6: Freeze configuration from other sources with a small helper
- What the output shows
- Check it worked
- Step 7: Use a frozendict inside a frozen dataclass
- A dict field leaves frozen=True half done
- A frozendict field closes both gaps
- The default check looks at the type, not the contents
- Check it worked
- Step 8: See how it behaves with the rest of Python
- Code that insists on a dict
- The left operand decides the type of a union
- Where a frozendict works without changes
- Hashable is not the same as hashed
- Hash values change from run to run
- Check it worked
- Step 9: Measure what it costs
- Reading the numbers
- Step 10: Put it together and test it
- Common mistakes and how to spot them
- Confirm the whole thing works end to end
- Where to go next
- Sources
In this tutorial you will learn what that buys you by building a small shipping-quote module whose rules come from a JSON or TOML file. Along the way you will:
- create a
frozendictand prove that it is read-only; - fix the classic “shared defaults” bug, where one caller silently changes settings for everyone;
- use dictionaries as set members, dictionary keys and
lru_cachearguments; - load JSON and TOML configuration as deeply immutable data, which is where most people get tripped up;
- learn the places where a
frozendictbehaves differently from adict, each with the exact error message you will see.
Two words will come up all the time, so here are plain definitions. An object is mutable if it can be changed after it is created (lists, dictionaries and sets are mutable) and immutable if it cannot (strings, tuples and frozensets). An object is hashable if Python can compute a fixed integer fingerprint for it, called its hash, that stays the same for as long as the object lives. Python needs that fingerprint to use an object as a dictionary key, as a set member, or as an argument to a cached function. A plain dictionary is not hashable, precisely because it can change.
The PEP’s rationale names three uses for an immutable mapping: using it as a dictionary key or set element, passing it to a function decorated with @functools.lru_cache(), and using it as a default parameter value to avoid the mutable-default problem. This tutorial covers all three, then spends most of its time on the places where people get caught out: nested data, interoperability and cost.
Prerequisites
- Python 3.15. I ran everything on Windows 11 with Python 3.15.0rc3, the release candidate python.org published on October 2, 2026. PEP 790, the 3.15 release schedule, lists 3.15.0 final as expected on Friday, October 9, 2026, so a final release may already be out when you read this. Any 3.15 build works.
- Basic Python. You should be comfortable with functions, dictionaries,
tryandexcept, and running a script from a terminal. - pytest for the last step (I used 9.1.1). Nothing else needs installing: every demo uses the standard library.
- Any operating system. The code is plain, portable Python, but I only ran it on Windows 11. Where a command differs between systems I show both forms.
Create the project folder
Make an empty folder and a virtual environment (an isolated set of installed packages, so nothing here touches your system Python). If you have several Pythons installed, use py -3.15 on Windows or python3.15 elsewhere in place of python.
mkdir frozen-config
cd frozen-config
python -m venv .venv
Activate the environment. On macOS and Linux that is source .venv/bin/activate. In Windows PowerShell it is .venv\Scripts\Activate.ps1; if PowerShell’s execution policy blocks that script, put the environment first on the path instead with $env:PATH = "$PWD\.venv\Scripts;$env:PATH". Then install pytest:
python -m pip install pytest
Every script in this tutorial starts with a comment that gives its file name, such as # step1_basics.py. Save each one under exactly that name in the project folder. When you are done the folder will look like this:
frozen-config/
rules.json rules.toml
freeze.py pricing.py test_pricing.py
step1_basics.py step2_readonly.py step3_hashing.py
step4_cache.py step5_deep.py step6_toml.py
step7_dataclass.py step8_interop.py step9_bench.py
Check that your Python has frozendict
Before writing any code, confirm that the interpreter you are about to use really is 3.15. The built-in only exists there:
python --version
python -c "print(frozendict)"
On my machine, with Python 3.15.0rc3, those two commands print:
Python 3.15.0rc3
<class 'frozendict'>
Check it worked
If the second line prints <class 'frozendict'>, you are ready. If you instead see a NameError, your python is an older release. This is what the same command prints on Python 3.13.14:
Python 3.13.14
NameError: name 'frozendict' is not defined. Did you mean: 'frozenset'?
The NameError is useful to remember: it is exactly what your program will do on any Python older than 3.15, so a project that must still support older versions cannot use the built-in directly.
Step 1: Create a frozendict and read from it
Start with the simplest possible program. Save this as step1_basics.py and run it with python step1_basics.py:
# step1_basics.py
from collections.abc import Mapping
rates = frozendict(base=5.0, per_kg=1.2)
print(rates)
print("type:", type(rates).__name__, "| parents:", type(rates).__mro__[1:])
print("is a dict:", isinstance(rates, dict), "| is a Mapping:", isinstance(rates, Mapping))
print("lookup:", rates["base"], "| get with default:", rates.get("fuel", 0.0))
print("length:", len(rates), "| keys:", list(rates), "| membership:", "per_kg" in rates)
from_kwargs = frozendict(base=5.0, per_kg=1.2)
from_dict = frozendict({"base": 5.0, "per_kg": 1.2})
from_pairs = frozendict([("base", 5.0), ("per_kg", 1.2)])
print("all three equal:", from_kwargs == from_dict == from_pairs)
print("fromkeys:", frozendict.fromkeys(["domestic", "international"], 0.0))
You should see:
frozendict({'base': 5.0, 'per_kg': 1.2})
type: frozendict | parents: (<class 'object'>,)
is a dict: False | is a Mapping: True
lookup: 5.0 | get with default: 0.0
length: 2 | keys: ['base', 'per_kg'] | membership: True
all three equal: True
fromkeys: frozendict({'domestic': 0.0, 'international': 0.0})
What just happened
frozendict(...) accepts the same three kinds of input as dict(...): keyword arguments, an existing mapping, or an iterable of key and value pairs. The three variables near the bottom of the script build identical objects, which is why the comparison prints True. Reading also works like a dictionary: square brackets, .get() with a default, len(), in, and iteration in insertion order.
Now look at the line that prints parents. A frozendict is not a dict with writing switched off. The documentation says it plainly: “frozendict is not a dict subclass but inherits directly from object.” It implements the Mapping protocol from collections.abc, the abstract type for read-only dictionary-like objects, which is why isinstance(rates, Mapping) is True and isinstance(rates, dict) is False. Step 8 shows why that distinction matters in real code.
Check it worked
Your output should match the block above line for line. The fromkeys line at the end is the same class method that dict has: it builds one mapping with the same value for every key.
Step 2: Prove it is read-only and fix the shared-defaults bug
The whole point of a frozendict is what it refuses to do. Save step2_readonly.py. It has five short sections, and I will show them one at a time with the output of each. Run the whole file with python step2_readonly.py.
Writes are refused
# step2_readonly.py
rates = frozendict(base=5.0, per_kg=1.2)
print("=== writes are refused")
try:
rates["base"] = 0.0
except TypeError as err:
print("TypeError:", err)
try:
del rates["base"]
except TypeError as err:
print("TypeError:", err)
for name in ("update", "pop", "popitem", "setdefault", "clear"):
try:
getattr(rates, name)
except AttributeError as err:
print("AttributeError:", err)
=== writes are refused
TypeError: 'frozendict' object does not support item assignment
TypeError: 'frozendict' object does not support item deletion
AttributeError: 'frozendict' object has no attribute 'update'
AttributeError: 'frozendict' object has no attribute 'pop'
AttributeError: 'frozendict' object has no attribute 'popitem'
AttributeError: 'frozendict' object has no attribute 'setdefault'
AttributeError: 'frozendict' object has no attribute 'clear'
Assigning to a key or deleting one raises TypeError. The mutating methods do not exist at all, so asking for them raises AttributeError instead. The documentation lists exactly what a dict has that a frozendict lacks: __delitem__(), __setitem__(), clear(), pop(), popitem(), setdefault() and update(). The loop in the script reads each attribute without calling it, which is enough to show that it is missing.
The shared-defaults bug with a plain dict
Now the bug this protects you from. A service keeps its default options in a module-level dictionary, and a helper builds the options for each call:
# step2_readonly.py (continued)
print("=== the shared defaults bug with a plain dict")
DEFAULTS = {"retries": 3, "timeout": 5.0}
def make_options(**overrides):
options = DEFAULTS # no copy: both names point at the same dict
options.update(overrides)
return options
print("first call: ", make_options(timeout=30.0))
print("second call:", make_options())
print("DEFAULTS now:", DEFAULTS)
=== the shared defaults bug with a plain dict
first call: {'retries': 3, 'timeout': 30.0}
second call: {'retries': 3, 'timeout': 30.0}
DEFAULTS now: {'retries': 3, 'timeout': 30.0}
The helper looks harmless, but options = DEFAULTS copies nothing. It gives the same dictionary a second name, so options.update(overrides) rewrites the shared defaults. The second call asked for no overrides at all, yet it received the 30-second timeout from the first call, and DEFAULTS itself has changed. Nothing crashed, and nothing in the output looks wrong unless you remember that the default was 5.0. Bugs of this kind are hard to find because the symptom shows up in a different request from the one that caused it.
The same code with a frozendict
Change only the first line so that DEFAULTS is a frozendict, and keep the buggy helper:
# step2_readonly.py (continued)
print("=== the same code with a frozendict")
DEFAULTS = frozendict(retries=3, timeout=5.0)
def make_options(**overrides):
options = DEFAULTS
options.update(overrides)
return options
try:
make_options(timeout=30.0)
except AttributeError as err:
print("AttributeError:", err)
=== the same code with a frozendict
AttributeError: 'frozendict' object has no attribute 'update'
The bug can no longer happen silently. The helper fails on the exact line that tried to change shared state. The crash is the feature: it moves the discovery from a mysterious symptom in production to a traceback that points at the mistake.
The fix: build a new mapping with the | operator
# step2_readonly.py (continued)
print("=== the fix: build a new mapping with |")
def make_options(**overrides):
return DEFAULTS | overrides
print("first call: ", make_options(timeout=30.0))
print("second call:", make_options())
print("DEFAULTS now:", DEFAULTS)
print("result type:", type(make_options()).__name__)
=== the fix: build a new mapping with |
first call: frozendict({'retries': 3, 'timeout': 30.0})
second call: frozendict({'retries': 3, 'timeout': 5.0})
DEFAULTS now: frozendict({'retries': 3, 'timeout': 5.0})
result type: frozendict
The | operator builds a new mapping from both operands, and when a key appears on both sides the value from the right operand wins. The first call returns the overridden mapping, the second call returns the untouched defaults, and the result is still a frozendict.
What |= really does
# step2_readonly.py (continued)
print("=== the augmented union rebinds the name and leaves the original alone")
original = DEFAULTS
updated = DEFAULTS
updated |= {"retries": 5}
print("updated: ", updated)
print("original:", original)
print("same object:", updated is original)
=== the augmented union rebinds the name and leaves the original alone
updated: frozendict({'retries': 5, 'timeout': 5.0})
original: frozendict({'retries': 3, 'timeout': 5.0})
same object: False
The augmented operator |= looks like an in-place update, but the documentation says: “frozendict |= other does not modify the frozendict in-place but creates a new frozen dictionary.” What happened is that updated |= {...} computed a new mapping and pointed the name updated at it. The object that original still refers to was never touched, and the output shows same object: False.
Remember what this implies. A frozendict protects the object, not the variable name. The PEP says so itself: “it remains possible to bind again a variable to a new modified frozendict or a new mutable dict.” Someone can still write DEFAULTS = something_else at module level, so a code review is still needed for that.
Check it worked
The two lines that matter are AttributeError: 'frozendict' object has no attribute 'update' in the third section and second call: frozendict({'retries': 3, 'timeout': 5.0}) in the fourth. If the second call shows 30.0, you are still using the buggy helper.
Step 3: Compare, hash and deduplicate
Because a frozendict cannot change, Python can give it a stable hash. The PEP spells out how: “The hash value does not depend on the items’ order. It is computed on keys and values,” and its pseudo-code is hash(frozenset(frozendict.items())). Equality works the same way. The Python 3.15 release notes put it in one sentence: a frozendict “preserves insertion order, but comparison does not take order into account.” Save step3_hashing.py and run it a section at a time.
Equality and hashing ignore order
# step3_hashing.py
from types import MappingProxyType
print("=== equality and hashing ignore order")
a = frozendict(zone="domestic", kg=2)
b = frozendict(kg=2, zone="domestic")
print("equal:", a == b, "| same hash:", hash(a) == hash(b))
print("equals a plain dict:", a == {"zone": "domestic", "kg": 2})
print("iteration keeps insertion order:", list(a), list(b))
=== equality and hashing ignore order
equal: True | same hash: True
equals a plain dict: True
iteration keeps insertion order: ['zone', 'kg'] ['kg', 'zone']
The two objects list their keys in different orders, yet they are equal and share a hash. A frozendict also compares equal to a plain dict with the same items. Iteration still follows insertion order, so the order is remembered; it just does not count when comparing.
Deduplicate requests with a set
# step3_hashing.py (continued)
print("=== dedupe requests with a set")
requests = [
{"zone": "domestic", "kg": 2},
{"kg": 2, "zone": "domestic"},
{"zone": "international", "kg": 2},
]
try:
set(requests)
except TypeError as err:
print("set of dicts:", err)
unique = {frozendict(r) for r in requests}
print(len(requests), "requests,", len(unique), "unique")
seen = {a: "quoted"}
print("lookup with the reordered twin:", seen[b])
=== dedupe requests with a set
set of dicts: cannot use 'dict' as a set element (unhashable type: 'dict')
3 requests, 2 unique
lookup with the reordered twin: quoted
A set only stores hashable objects, so a set of dictionaries fails. Python’s message, cannot use 'dict' as a set element (unhashable type: 'dict'), is the same wall you hit with dictionary keys. Wrapping each request in frozendict(...) removes the wall. The first two requests are the same request with their keys listed in a different order, so three requests collapse into two. The last line shows a frozendict working as a dictionary key: the reordered twin finds the stored entry.
Why not just use MappingProxyType?
The standard library already had a read-only wrapper, types.MappingProxyType. Its documentation describes it as a “Read-only proxy of a mapping” that “provides a dynamic view on the mapping’s entries, which means that when the mapping changes, the view reflects these changes.” Compare the two:
# step3_hashing.py (continued)
print("=== why not just MappingProxyType?")
base = {"base": 5.0}
view = MappingProxyType(base)
snapshot = frozendict(base)
base["base"] = 0.0
print("view sees the change:", view["base"], "| snapshot does not:", snapshot["base"])
try:
hash(view)
except TypeError as err:
print("hash(view):", err)
print("a proxy over a frozendict hashes like it:", hash(MappingProxyType(snapshot)) == hash(snapshot))
=== why not just MappingProxyType?
view sees the change: 0.0 | snapshot does not: 5.0
hash(view): unhashable type: 'dict'
a proxy over a frozendict hashes like it: True
A proxy is a read-only window onto a dictionary that somebody else may still change, and the first line of output shows the window reflecting that change. A frozendict is a snapshot: it copied the data when it was created, so later changes to the source do not reach it. The proxy over a plain dictionary is also unhashable, so it cannot be a cache key. In my run, a proxy wrapped around a frozendict did hash like the frozendict it wraps.
Use a proxy when you want to expose a live, read-only view of something you keep updating, such as an internal registry. Use a frozendict when you want a value that stays put.
Check it worked
The lines to look for are equal: True | same hash: True, 3 requests, 2 unique, and view sees the change: 0.0 | snapshot does not: 5.0.
Step 4: Cache a function that takes a mapping
A cache remembers the answer for a given input so that repeated calls skip the work. The standard library decorator functools.lru_cache does this for any function. It stores answers in a dictionary keyed by the arguments, which is why the documentation warns: “Since a dictionary is used to cache results, the positional and keyword arguments to the function must be hashable.” Save step4_cache.py.
A dict argument cannot be cached
# step4_cache.py
from functools import lru_cache
calls = 0
@lru_cache(maxsize=None)
def quote_total(rates, kg):
global calls
calls += 1
return round(rates["base"] + rates["per_kg"] * kg, 2)
print("=== a dict argument cannot be cached")
plain = {"base": 5.0, "per_kg": 1.2}
try:
quote_total(plain, 2)
except TypeError as err:
print("TypeError:", err)
=== a dict argument cannot be cached
TypeError: unhashable type: 'dict'
The cached function takes a rate table and a weight. Passing the table as a plain dictionary fails before the function even runs, because lru_cache cannot turn the argument into a key.
A frozendict argument can
# step4_cache.py (continued)
print("=== a frozendict argument can")
rates = frozendict(plain)
print(quote_total(rates, 2), quote_total(rates, 2), "| function body ran", calls, "time(s)")
print(quote_total.cache_info())
reordered = frozendict(per_kg=1.2, base=5.0)
print(quote_total(reordered, 2), "| function body ran", calls, "time(s)")
print(quote_total.cache_info())
=== a frozendict argument can
7.4 7.4 | function body ran 1 time(s)
CacheInfo(hits=1, misses=1, maxsize=None, currsize=1)
7.4 | function body ran 1 time(s)
CacheInfo(hits=2, misses=1, maxsize=None, currsize=1)
The first two calls return the same answer, but the function body ran only once and cache_info() reports one miss and one hit. Then comes the interesting part. The second table lists its keys in a different order, yet the call is a hit, so the body still ran only once. The documentation warns that f(a=1, b=2) and f(b=2, a=1) “differ in their keyword argument order and may have two separate cache entries.” A frozendict argument does not have that problem, because equality and hashing ignore key order.
A cache hands every caller the same object
One more trap, and it is not specific to frozendict. The cache stores the object your function returned and gives that same object to every caller:
# step4_cache.py (continued)
print("=== a cache hands every caller the same object")
@lru_cache(maxsize=None)
def breakdown_dict(rates, kg):
return {"kg": kg, "total": quote_total(rates, kg)}
mine = breakdown_dict(rates, 2)
mine["total"] = 0.0
print("what the next caller gets:", breakdown_dict(rates, 2))
@lru_cache(maxsize=None)
def breakdown(rates, kg):
return frozendict(kg=kg, total=quote_total(rates, kg))
mine = breakdown(rates, 2)
try:
mine["total"] = 0.0
except TypeError as err:
print("TypeError:", err)
print("what the next caller gets:", breakdown(rates, 2))
=== a cache hands every caller the same object
what the next caller gets: {'kg': 2, 'total': 0.0}
TypeError: 'frozendict' object does not support item assignment
what the next caller gets: frozendict({'kg': 2, 'total': 7.4})
In the first half, one caller “adjusted” its copy of the breakdown, and the next caller received a total of 0.0, because the cache never copies what it stores. In the second half the function returns a frozendict, so the same attempt fails immediately and the next caller still gets 7.4. Returning an immutable result is the cheapest way to make a cache safe.
Check it worked
You want CacheInfo(hits=2, misses=1, maxsize=None, currsize=1) after the reordered call. If misses is 2, your two tables were not equal.
Step 5: Freeze every level, not just the top
Real configuration is nested: a table of zones, each with a list of carriers. Create rules.json with this content:
{
"currency": "USD",
"default_zone": "domestic",
"zones": {
"domestic": {"base": 5.0, "per_kg": 1.2, "carriers": ["ground", "air"]},
"international": {"base": 15.0, "per_kg": 3.5, "carriers": ["air"]}
},
"blocked_countries": ["XX", "YY"],
"limits": {"max_kg": 30}
}
frozendict(some_dict) copies only the first level. The PEP says the items are copied as a “shallow copy,” and the cost of the copy is O(n) in the number of items. Anything nested inside is shared with the original, and it is still mutable. Save step5_deep.py to see what that means.
Freezing only the top level
# step5_deep.py
import json
from pathlib import Path
text = Path("rules.json").read_text(encoding="utf-8")
print("=== freezing only the top level")
loaded = json.loads(text)
shallow = frozendict(loaded)
shallow["blocked_countries"].append("ZZ")
shallow["zones"]["domestic"]["base"] = 0.0
print("nested list:", shallow["blocked_countries"])
print("nested value:", shallow["zones"]["domestic"]["base"])
print("the original dict saw it too:", loaded["zones"]["domestic"]["base"])
try:
hash(shallow)
except TypeError as err:
print("hash:", err)
=== freezing only the top level
nested list: ['XX', 'YY', 'ZZ']
nested value: 0.0
the original dict saw it too: 0.0
hash: unhashable type: 'dict'
The top level is frozen, but nothing underneath it is. The blocked-countries list gained a new entry, a price was set to zero, and the original dictionary saw both changes because the nested objects were shared. The object is called frozen, yet it cannot be hashed, because its values include dictionaries and lists.
Freezing every level while parsing
Python 3.15 gives you a clean way to do this for JSON. The release notes say: “Passing combined frozendict to object_pairs_hook parameter and tuple to array_hook will yield a deeply nested immutable Python structure representing the JSON data.” The array_hook parameter is new in 3.15, so this recipe does not exist in older versions.
# step5_deep.py (continued)
print("=== freezing every level while parsing")
rules = json.loads(text, object_pairs_hook=frozendict, array_hook=tuple)
print(rules["zones"]["domestic"])
print("blocked countries:", rules["blocked_countries"])
try:
rules["blocked_countries"].append("ZZ")
except AttributeError as err:
print("AttributeError:", err)
try:
rules["zones"]["domestic"]["base"] = 0.0
except TypeError as err:
print("TypeError:", err)
print("hashable:", isinstance(hash(rules), int))
=== freezing every level while parsing
frozendict({'base': 5.0, 'per_kg': 1.2, 'carriers': ('ground', 'air')})
blocked countries: ('XX', 'YY')
AttributeError: 'tuple' object has no attribute 'append'
TypeError: 'frozendict' object does not support item assignment
hashable: True
Here is how it works. json.loads parses from the inside out. For every JSON object it calls object_pairs_hook with a list of (key, value) pairs, and frozendict accepts a list of pairs. For every JSON array it calls array_hook with the decoded list, and tuple turns it into a tuple. By the time the outer object is built, every level is already immutable. Appending to a list now fails because tuples have no append, assignment fails because a frozendict has no item assignment, and the whole structure hashes.
Why object_hook alone is not enough
# step5_deep.py (continued)
print("=== object_hook alone is not enough")
half = json.loads(text, object_hook=frozendict)
try:
hash(half)
except TypeError as err:
print("hash:", err)
=== object_hook alone is not enough
hash: unhashable type: 'list'
It is tempting to pass only object_hook=frozendict. That freezes every JSON object, but arrays stay lists, so the structure still cannot be hashed. You need both hooks.
Writing it back out
# step5_deep.py (continued)
print("=== writing it back out")
dumped = json.dumps(rules, sort_keys=True)
print(dumped[:76] + "...")
again = json.loads(dumped, object_pairs_hook=frozendict, array_hook=tuple)
print("round trip equal:", again == rules)
=== writing it back out
{"blocked_countries": ["XX", "YY"], "currency": "USD", "default_zone": "dome...
round trip equal: True
The json documentation records the change that makes this work: “Changed in version 3.15: Added support for frozendict.” Tuples are written as JSON arrays, so parsing the output with the same two hooks gives back an equal value.
Check it worked
After the second section you should see AttributeError: 'tuple' object has no attribute 'append', TypeError: 'frozendict' object does not support item assignment and hashable: True. If the append works, you are still looking at the shallow version.
Step 6: Freeze configuration from other sources with a small helper
TOML is a popular format for configuration, and Python reads it with tomllib. Its documentation says tomllib.load and tomllib.loads “Return a dict,” and their only keyword option is parse_float. There are no hooks for objects or arrays, so you freeze the result afterwards. Create rules.toml, which holds the same rules as the JSON file with the keys in a different order:
# rules.toml
blocked_countries = ["XX", "YY"]
currency = "USD"
default_zone = "domestic"
[zones.domestic]
base = 5.0
per_kg = 1.2
carriers = ["ground", "air"]
[zones.international]
base = 15.0
per_kg = 3.5
carriers = ["air"]
[limits]
max_kg = 30
Then create freeze.py, the small module the rest of the tutorial imports:
# freeze.py
import json
import tomllib
from pathlib import Path
def deep_freeze(value):
"""Return an immutable, hashable copy of nested dict, list and set data."""
if isinstance(value, (dict, frozendict)):
return frozendict({key: deep_freeze(item) for key, item in value.items()})
if isinstance(value, (list, tuple)):
return tuple(deep_freeze(item) for item in value)
if isinstance(value, (set, frozenset)):
return frozenset(deep_freeze(item) for item in value)
return value
def load_json_rules(path):
text = Path(path).read_text(encoding="utf-8")
return json.loads(text, object_pairs_hook=frozendict, array_hook=tuple)
def load_toml_rules(path):
with open(path, "rb") as handle:
return deep_freeze(tomllib.load(handle))
deep_freeze walks the data and rebuilds each level as an immutable type. Dictionaries become frozendict, lists and tuples become tuples, sets become frozensets, and anything else is returned as it is, on the assumption that leaf values such as strings, numbers, booleans and dates are already immutable. The first check, isinstance(value, (dict, frozendict)), is the form the release notes recommend for code that must accept both types. The function assumes the data is a tree with no cycles, which is true of anything parsed from JSON or TOML. The two loaders at the bottom give you one entry point per format. Save step6_toml.py and run it:
# step6_toml.py
import tomllib
from pathlib import Path
from freeze import deep_freeze, load_json_rules, load_toml_rules
print("=== tomllib returns plain, mutable containers")
raw = tomllib.loads(Path("rules.toml").read_text(encoding="utf-8"))
print(type(raw).__name__, type(raw["zones"]).__name__, type(raw["blocked_countries"]).__name__)
print("=== deep_freeze converts every level")
frozen = deep_freeze(raw)
print(type(frozen).__name__, type(frozen["zones"]).__name__, type(frozen["blocked_countries"]).__name__)
print("hashable:", isinstance(hash(frozen), int))
print("the source dict is untouched:", type(raw["zones"]).__name__)
print("=== TOML and JSON, one value")
from_toml = load_toml_rules("rules.toml")
from_json = load_json_rules("rules.json")
print("equal:", from_toml == from_json, "| same hash:", hash(from_toml) == hash(from_json))
print("first keys differ:", list(from_toml)[0], "vs", list(from_json)[0])
print("=== sets become frozensets")
print(deep_freeze({"ids": {3, 1, 2}, "weights": [1, 2, [3, 4]]}))
print("=== wrapping something that is already frozen")
print("deep_freeze(frozen) == frozen:", deep_freeze(frozen) == frozen)
print("deep_freeze(frozen) is frozen:", deep_freeze(frozen) is frozen)
print("frozendict(frozen) is frozen:", frozendict(frozen) is frozen)
=== tomllib returns plain, mutable containers
dict dict list
=== deep_freeze converts every level
frozendict frozendict tuple
hashable: True
the source dict is untouched: dict
=== TOML and JSON, one value
equal: True | same hash: True
first keys differ: blocked_countries vs currency
=== sets become frozensets
frozendict({'ids': frozenset({1, 2, 3}), 'weights': (1, 2, (3, 4))})
=== wrapping something that is already frozen
deep_freeze(frozen) == frozen: True
deep_freeze(frozen) is frozen: False
frozendict(frozen) is frozen: True
What the output shows
tomllib returns plain dictionaries and lists, and deep_freeze turns every level into its frozen counterpart without touching the input. The two loaders produce equal values with equal hashes even though the files list their keys in a different order, because equality ignores order. That is a handy property: a cache keyed on the rules does not care which file format they came from.
The last section is a cost lesson. frozendict(frozen) returns the very same object, so wrapping something that is already frozen is free. deep_freeze(frozen) builds a brand-new structure level by level, so call it once when you load the configuration and not on every request.
Check it worked
Look for equal: True | same hash: True in the third section. If it prints False, compare the two files: a value such as 30 in one and 30.0 in the other would still be equal, but a missing key would not.
Step 7: Use a frozendict inside a frozen dataclass
A dataclass is a class whose __init__, __repr__ and __eq__ are generated from its annotated fields. With frozen=True, assigning to a field raises an error, and the documentation adds: “If eq and frozen are both true, by default @dataclass will generate a __hash__() method for you.” That generated hash is computed from the field values, so by default every field must be hashable. Save step7_dataclass.py.
A dict field leaves frozen=True half done
# step7_dataclass.py
from dataclasses import dataclass
print("=== frozen=True is shallow too")
@dataclass(frozen=True)
class DictRequest:
zone: str
options: dict
req = DictRequest("domestic", {"signature": True})
try:
req.zone = "international"
except Exception as err:
print(type(err).__name__ + ":", err)
req.options["signature"] = False
print("nested change allowed:", req)
try:
hash(req)
except TypeError as err:
print("hash:", err)
=== frozen=True is shallow too
FrozenInstanceError: cannot assign to field 'zone'
nested change allowed: DictRequest(zone='domestic', options={'signature': False})
hash: unhashable type: 'dict'
Assigning to zone is blocked, but req.options["signature"] = False went straight through, because the dataclass freezes the field, not the dictionary the field points to. The class also looks hashable until you try to hash an instance: the failure appears at the first hash() call or the first cached call, not when the object is created.
A frozendict field closes both gaps
# step7_dataclass.py (continued)
print("=== a frozendict field closes both gaps")
@dataclass(frozen=True)
class QuoteRequest:
zone: str
kg: float
options: frozendict = frozendict()
a = QuoteRequest("domestic", 2.0, frozendict(signature=True))
b = QuoteRequest("domestic", 2.0, frozendict(signature=True))
print("equal:", a == b, "| same hash:", hash(a) == hash(b), "| in a set:", len({a, b}))
try:
a.options["signature"] = False
except TypeError as err:
print("TypeError:", err)
print("default options:", QuoteRequest("domestic", 1.0))
=== a frozendict field closes both gaps
equal: True | same hash: True | in a set: 1
TypeError: 'frozendict' object does not support item assignment
default options: QuoteRequest(zone='domestic', kg=1.0, options=frozendict())
Now the request is immutable all the way down, hashable, and usable in sets and as a cache key. Notice the default value, options: frozendict = frozendict(). With a plain dictionary as the default, dataclass refuses to create the class at all:
# step7_dataclass.py (continued)
print("=== dataclasses refuse a dict default")
try:
@dataclass
class Bad:
options: dict = {}
except ValueError as err:
print("ValueError:", err)
=== dataclasses refuse a dict default
ValueError: mutable default <class 'dict'> for field options is not allowed: use default_factory
The error message points to default_factory, the usual workaround for mutable defaults. The documentation explains the rule, added in Python 3.11: “unhashable objects are now not allowed as default values. Unhashability is used to approximate mutability.” An empty frozendict is hashable, so it is accepted as a plain default.
The default check looks at the type, not the contents
# step7_dataclass.py (continued)
print("=== but the check looks at the class, not at the contents")
@dataclass
class Sneaky:
options: frozendict = frozendict(tags=["a"])
first, second = Sneaky(), Sneaky()
first.options["tags"].append("b")
print("the other instance sees:", second.options)
=== but the check looks at the class, not at the contents
the other instance sees: frozendict({'tags': ['a', 'b']})
This is the trap inside the convenience. The check is made on the class of the default value, so a frozendict that holds a list is accepted even though that list is shared by every instance. The second instance saw the change made through the first. Build defaults with deep_freeze or the JSON hooks so that nothing mutable hides inside.
Check it worked
You should see FrozenInstanceError: cannot assign to field 'zone', then equal: True | same hash: True | in a set: 1, and finally the ValueError about a mutable dict default.
Step 8: See how it behaves with the rest of Python
A frozendict is a new type, so some existing code reacts to it in surprising ways. The release notes list the standard library modules updated to accept it: copy, decimal, json, marshal, plistlib (only for serialization), pickle, pprint and xml.etree.ElementTree. Code outside the standard library works with the new type only if its authors have accounted for it. Save step8_interop.py and walk through its five sections.
Code that insists on a dict
# step8_interop.py
import json
import pickle
import pprint
import subprocess
import sys
from collections.abc import Hashable, Mapping
rates = frozendict(base=5.0, per_kg=1.2)
print("=== code that insists on a dict")
def load_settings(raw):
if not isinstance(raw, dict):
raise TypeError(f"settings must be a dict, got {type(raw).__name__}")
return raw
try:
load_settings(rates)
except TypeError as err:
print("TypeError:", err)
def load_settings(raw):
if not isinstance(raw, Mapping):
raise TypeError(f"settings must be a mapping, got {type(raw).__name__}")
return raw
print("with Mapping:", load_settings(rates))
print("tuple form also works:", isinstance(rates, (dict, frozendict)))
print("type hints:", frozendict[str, float], "or Mapping[str, float]")
=== code that insists on a dict
TypeError: settings must be a dict, got frozendict
with Mapping: frozendict({'base': 5.0, 'per_kg': 1.2})
tuple form also works: True
type hints: frozendict[str, float] or Mapping[str, float]
A validator written as isinstance(raw, dict) rejects a frozendict, because it is not a subclass. The release notes give two ways to update such code: check isinstance(arg, (dict, frozendict)) to accept both types, or check isinstance(arg, collections.abc.Mapping) to accept other mapping types as well, such as MappingProxyType. For type hints, the documentation says frozendict is generic over its key and value types, so frozendict[str, float] is valid, and Mapping[str, float] is the better choice for a function parameter that should accept anything read-only.
The left operand decides the type of a union
# step8_interop.py (continued)
print("=== the left operand decides the type of a union")
print(type(rates | {"fuel": 0.3}).__name__, "|", type({"fuel": 0.3} | rates).__name__)
=== the left operand decides the type of a union
frozendict | dict
The PEP’s examples show a frozendict on the left of |, and that gives a frozendict. With a plain dictionary on the left, I measured a plain dictionary back. So {"fuel": 0.3} | rates quietly hands you a mutable result. When you need the frozen type, put a frozendict on the left or wrap the result in frozendict(...).
Where a frozendict works without changes
# step8_interop.py (continued)
print("=== it works wherever a mapping is expected")
def describe(**kwargs):
return sorted(kwargs)
print(describe(**rates), {**rates}, dict(rates))
print(json.dumps(rates), pickle.loads(pickle.dumps(rates)) == rates)
pprint.pprint(frozendict(zone="domestic", carriers=("ground", "air"), signature=True), width=40)
def describe_mode(config):
match config:
case {"mode": "fast", **rest}:
return f"fast, other keys: {sorted(rest)}"
case _:
return "default"
print(describe_mode(frozendict(mode="fast", retries=3)))
=== it works wherever a mapping is expected
['base', 'per_kg'] {'base': 5.0, 'per_kg': 1.2} {'base': 5.0, 'per_kg': 1.2}
{"base": 5.0, "per_kg": 1.2} True
frozendict({'carriers': ('ground',
'air'),
'signature': True,
'zone': 'domestic'})
fast, other keys: ['retries']
Keyword unpacking, dictionary unpacking, dict(...), json.dumps, pickle and pprint all accept it, and a match statement’s mapping pattern matched it, with **rest collecting the remaining keys. pprint prints the keys in sorted order and wraps the long line.
Hashable is not the same as hashed
# step8_interop.py (continued)
print("=== hashable is not the same as hashed")
risky = frozendict(tags=["a", "b"])
print("isinstance(risky, Hashable):", isinstance(risky, Hashable))
try:
hash(risky)
except TypeError as err:
print("hash(risky):", err)
=== hashable is not the same as hashed
isinstance(risky, Hashable): True
hash(risky): unhashable type: 'list'
The abstract type Hashable is documented as an “ABC for classes that provide the __hash__() method,” and a frozendict provides one. Whether the call succeeds depends on the contents, just as with a tuple that holds a list. The language reference warns about a closely related case: a class whose own __hash__() raises TypeError “would be incorrectly identified as hashable by an isinstance(obj, collections.abc.Hashable) call.” If you need to know whether a particular value can be hashed, call hash() inside a try block.
Hash values change from run to run
# step8_interop.py (continued)
print("=== hash values change from run to run")
code = (
"import hashlib, json\n"
"r = frozendict(a=1, b=2)\n"
"print(hash(r), hashlib.sha256(json.dumps(r, sort_keys=True).encode()).hexdigest()[:12])"
)
for _ in range(2):
result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
hash_value, fingerprint = result.stdout.split()
print("hash():", hash_value, "| sha256 of the sorted JSON:", fingerprint)
=== hash values change from run to run
hash(): 6074772582385742566 | sha256 of the sorted JSON: d8497d9d8277
hash(): -2627924748299122507 | sha256 of the sorted JSON: d8497d9d8277
The two processes printed different hash values for the same data. The Python reference explains why: “By default, the __hash__() values of str and bytes objects are ‘salted’ with an unpredictable random value. Although they remain constant within an individual Python process, they are not predictable between repeated invocations of Python.” So never write hash() results to a file or database or use them as identifiers. When you need a fingerprint that survives restarts, hash a canonical text form instead: the SHA-256 of the sorted JSON came out the same in both runs. Your two hash() numbers will differ from mine, but your fingerprints should match mine exactly.
Check it worked
Expect frozendict | dict on the union line, isinstance(risky, Hashable): True followed by an unhashable type: 'list' error, and two different hash() numbers beside one repeated fingerprint.
Step 9: Measure what it costs
Immutability is only a good trade if it does not slow the program down. Save step9_bench.py. It uses timeit and reports the best of five runs, in microseconds per call:
# step9_bench.py
import timeit
from time import perf_counter
from types import MappingProxyType
def best(fn, number, repeat=5):
"""Best of several runs, in microseconds per call."""
return min(timeit.repeat(fn, number=number, repeat=repeat)) / number * 1e6
print("=== building one")
for size in (1_000, 100_000):
source = {str(i): i for i in range(size)}
number = 200 if size == 1_000 else 20
print(
f"size {size:>7,}:",
f"dict(source) {best(lambda: dict(source), number):9.1f} us |",
f"frozendict(source) {best(lambda: frozendict(source), number):9.1f} us |",
f"MappingProxyType(source) {best(lambda: MappingProxyType(source), number):5.2f} us",
)
print("=== reading one")
source = {str(i): i for i in range(100_000)}
frozen = frozendict(source)
keys = list(source)[:50]
print(
"50 lookups, best of 5:",
f"dict {best(lambda: [source[k] for k in keys], 2000):.1f} us |",
f"frozendict {best(lambda: [frozen[k] for k in keys], 2000):.1f} us",
)
print("=== hashing one")
start = perf_counter()
hash(frozen)
first = perf_counter() - start
start = perf_counter()
hash(frozen)
second = perf_counter() - start
print(f"first hash of 100,000 items {first * 1e6:.0f} us | second hash {second * 1e6:.1f} us")
print("=== freezing something already frozen")
print("frozendict(frozen) is frozen:", frozendict(frozen) is frozen, "| frozen.copy() is frozen:", frozen.copy() is frozen)
print(f"frozendict(frozen) takes {best(lambda: frozendict(frozen), 20):.2f} us")
=== building one
size 1,000: dict(source) 1.9 us | frozendict(source) 1.9 us | MappingProxyType(source) 0.04 us
size 100,000: dict(source) 1556.8 us | frozendict(source) 1549.2 us | MappingProxyType(source) 0.04 us
=== reading one
50 lookups, best of 5: dict 0.5 us | frozendict 0.5 us
=== hashing one
first hash of 100,000 items 397 us | second hash 0.3 us
=== freezing something already frozen
frozendict(frozen) is frozen: True | frozen.copy() is frozen: True
frozendict(frozen) takes 0.02 us
Reading the numbers
- Building one. Creating a
frozendictfrom a dictionary took the same time as copying the dictionary. Over three runs the two stayed within about 1.5 percent of each other at both sizes. The PEP says the copy is O(n), and the 100,000-item row shows it: about 1.5 milliseconds. AMappingProxyTypecosts almost nothing because it copies nothing, which is also why it is not a snapshot. - Reading one. Fifty lookups took the same time in both types, 0.5 microseconds in every run.
- Hashing one. The first hash of 100,000 items took about 0.4 milliseconds, and the second took about half a microsecond. That pattern suggests the hash is remembered after the first call. I did not find this stated in the documentation, so treat it as something I observed on 3.15.0rc3, not as a promise.
- Wrapping something already frozen.
frozendict(frozen)andfrozen.copy()both returned the same object. The PEP says ofcopy(): “In CPython, it simply returns the same frozendict.”
These timings come from one Windows 11 machine, so run the script to see yours. The ratios are the lesson: freeze once, at startup or when you load a file, and pass the result around. Do not rebuild it inside a request handler.
Step 10: Put it together and test it
Now combine everything into the small module the tutorial promised. pricing.py defines a frozen dataclass for a quote request and a cached function that prices it against the rules:
# pricing.py
from dataclasses import dataclass
from functools import lru_cache
@dataclass(frozen=True)
class QuoteRequest:
zone: str
kg: float
options: frozendict = frozendict()
@lru_cache(maxsize=256)
def quote(rules, request):
limit = rules["limits"]["max_kg"]
if request.kg > limit:
raise ValueError(f"{request.kg} kg is over the {limit} kg limit")
zone = rules["zones"][request.zone]
total = zone["base"] + zone["per_kg"] * request.kg
if request.options.get("signature"):
total += 2.5
return frozendict(
zone=request.zone,
kg=request.kg,
total=round(total, 2),
carriers=zone["carriers"],
)
The request is hashable because its options field is a frozendict. The rules argument is hashable because the loaders in freeze.py freeze it all the way down. The function returns a frozendict, so a caller cannot corrupt the cached answer. Exceptions are not cached, which the last test checks.
Save the tests as test_pricing.py. Each test name states a rule that this tutorial demonstrated, and the file locates rules.json and rules.toml relative to itself, so you can run it from any folder:
# test_pricing.py
from dataclasses import dataclass
from pathlib import Path
import pytest
from freeze import deep_freeze, load_json_rules, load_toml_rules
from pricing import QuoteRequest, quote
HERE = Path(__file__).parent
RULES_JSON = HERE / "rules.json"
RULES_TOML = HERE / "rules.toml"
@pytest.fixture(autouse=True)
def fresh_cache():
quote.cache_clear()
def test_frozendict_refuses_writes():
rates = frozendict(base=5.0)
with pytest.raises(TypeError):
rates["base"] = 0.0
assert not hasattr(rates, "update")
def test_union_builds_a_new_mapping_and_the_left_type_wins():
defaults = frozendict(retries=3)
changed = defaults | {"retries": 5}
assert (defaults["retries"], changed["retries"]) == (3, 5)
assert type(changed) is frozendict
assert type({"retries": 5} | defaults) is dict
def test_equality_and_hash_ignore_order():
a = frozendict(x=1, y=2)
b = frozendict(y=2, x=1)
assert a == b
assert hash(a) == hash(b)
assert a == {"x": 1, "y": 2}
def test_freezing_only_the_top_level_leaves_nested_data_mutable():
shallow = frozendict({"tags": ["a"]})
shallow["tags"].append("b")
assert shallow["tags"] == ["a", "b"]
with pytest.raises(TypeError):
hash(shallow)
def test_json_hooks_freeze_every_level():
rules = load_json_rules(RULES_JSON)
assert isinstance(hash(rules), int)
with pytest.raises(TypeError):
rules["zones"]["domestic"]["base"] = 0.0
with pytest.raises(AttributeError):
rules["blocked_countries"].append("ZZ")
def test_toml_and_json_loaders_agree():
assert load_toml_rules(RULES_TOML) == load_json_rules(RULES_JSON)
def test_deep_freeze_converts_lists_sets_and_dicts():
frozen = deep_freeze({"ids": {3, 1, 2}, "pair": [1, [2, 3]], "inner": {"k": [4]}})
expected = frozendict(ids=frozenset({1, 2, 3}), pair=(1, (2, 3)), inner=frozendict(k=(4,)))
assert frozen == expected
assert isinstance(hash(frozen), int)
def test_deep_freeze_does_not_touch_its_input():
raw = {"zones": {"a": [1]}}
deep_freeze(raw)
assert raw == {"zones": {"a": [1]}}
assert type(raw["zones"]) is dict
def test_quote_is_cached_by_value_not_by_identity():
first = quote(load_json_rules(RULES_JSON), QuoteRequest("domestic", 2.0))
second = quote(load_toml_rules(RULES_TOML), QuoteRequest("domestic", 2.0))
assert first is second
assert first["total"] == 7.4
info = quote.cache_info()
assert (info.hits, info.misses) == (1, 1)
def test_a_cached_result_cannot_be_changed_by_a_caller():
result = quote(load_json_rules(RULES_JSON), QuoteRequest("domestic", 2.0))
with pytest.raises(TypeError):
result["total"] = 0.0
def test_options_change_the_price_and_the_cache_key():
rules = load_json_rules(RULES_JSON)
plain = quote(rules, QuoteRequest("domestic", 2.0))
signed = quote(rules, QuoteRequest("domestic", 2.0, frozendict(signature=True)))
assert (plain["total"], signed["total"]) == (7.4, 9.9)
assert quote.cache_info().misses == 2
def test_a_frozen_dataclass_with_a_dict_field_is_not_hashable():
@dataclass(frozen=True)
class Loose:
options: dict
with pytest.raises(TypeError):
hash(Loose({}))
def test_a_failed_quote_is_not_cached():
rules = load_json_rules(RULES_JSON)
with pytest.raises(ValueError):
quote(rules, QuoteRequest("domestic", 31.0))
assert quote.cache_info().currsize == 0
Run the suite with python -m pytest -v. The output on my machine, trimmed to the test results, was:
test_pricing.py::test_frozendict_refuses_writes PASSED [ 7%]
test_pricing.py::test_union_builds_a_new_mapping_and_the_left_type_wins PASSED [ 15%]
test_pricing.py::test_equality_and_hash_ignore_order PASSED [ 23%]
test_pricing.py::test_freezing_only_the_top_level_leaves_nested_data_mutable PASSED [ 30%]
test_pricing.py::test_json_hooks_freeze_every_level PASSED [ 38%]
test_pricing.py::test_toml_and_json_loaders_agree PASSED [ 46%]
test_pricing.py::test_deep_freeze_converts_lists_sets_and_dicts PASSED [ 53%]
test_pricing.py::test_deep_freeze_does_not_touch_its_input PASSED [ 61%]
test_pricing.py::test_quote_is_cached_by_value_not_by_identity PASSED [ 69%]
test_pricing.py::test_a_cached_result_cannot_be_changed_by_a_caller PASSED [ 76%]
test_pricing.py::test_options_change_the_price_and_the_cache_key PASSED [ 84%]
test_pricing.py::test_a_frozen_dataclass_with_a_dict_field_is_not_hashable PASSED [ 92%]
test_pricing.py::test_a_failed_quote_is_not_cached PASSED [100%]
============================= 13 passed in 0.01s ==============================
One test deserves a second look. test_quote_is_cached_by_value_not_by_identity loads the rules once from JSON and once from TOML, which creates two different objects. The second call is still a cache hit and returns the very same result object, because the two frozendict values are equal and hash alike. That is the practical payoff of everything in Steps 3 to 6.
Common mistakes and how to spot them
Every row below was reproduced while building this tutorial:
| Mistake | What you will see | The fix |
|---|---|---|
| Freezing only the top level | Nested values still change, and hash() raises unhashable type: 'dict' |
Use the JSON hooks or deep_freeze (Steps 5 and 6) |
object_hook=frozendict without array_hook=tuple |
hash() raises unhashable type: 'list' |
Pass both hooks |
Checking isinstance(x, dict) |
A frozendict is rejected | Check Mapping, or (dict, frozendict) |
Writing dict | frozendict |
A plain, mutable dict comes back | Put the frozendict on the left, or wrap the result |
Storing hash() values |
A different number on every run | Hash the sorted JSON with hashlib |
| Returning a mutable object from a cached function | The next caller sees the previous caller’s edits | Return a frozendict or a tuple |
| A frozendict default that holds a list | Accepted by dataclass, then shared by every instance |
Build defaults with deep_freeze |
Trusting isinstance(x, Hashable) |
True, then TypeError at hash() |
Call hash() in a try block |
Calling deep_freeze per request |
A full rebuild of the structure on every call | Freeze once at load time |
Confirm the whole thing works end to end
Finish with a full pass from a clean terminal in the project folder:
- Run
python --versionand confirm it prints a 3.15 version. - Run each
step*.pyscript, for example withpython step2_readonly.py, and compare the output with the blocks above. Only the twohash()numbers in Step 8 and the timings in Step 9 should differ from mine. - Run
python -m pytest -vand confirm that all 13 tests pass. - Edit
rules.jsonand add"extra": 1to thedomesticzone, then run the tests again. When I did this, exactly two tests failed,test_toml_and_json_loaders_agreeandtest_quote_is_cached_by_value_not_by_identity, and the summary read2 failed, 11 passed. That proves the tests notice when the two files drift apart. Undo the edit and confirm that you are back to 13 passing tests.
If all four checks behave as described, you have a cached, immutable configuration pipeline that cannot be modified by accident.
Where to go next
- Read the documentation: the frozendict section of the standard types page is the canonical reference now, and PEP 814 describes itself as a historical document.
- Learn the other side of the aliasing problem in How to Deep Copy Python Objects and Avoid Shared-State Bugs, which covers the shared-state bugs that frozendict prevents.
- Build the cache itself from scratch in How to Build an LRU Cache From Scratch in Python, and see how decorators work in How to Write Python Decorators From Scratch.
- Continue with the other Python 3.15 features this site has tested: lazy imports for faster startup, the Tachyon sampling profiler, and the UTF-8 default.
- If your code has to run on Python 3.14 or older, remember the
NameErrorfrom the start of this tutorial. The PEP notes that third-partyfrozendictandfrozenmappackages already exist on PyPI. I did not test any of them here, and since the built-in is not adictsubclass, check how a package’s type answersisinstance(x, dict)before swapping one for the other.
Sources
- PEP 814, Add frozendict built-in type: specification, rationale, union operators, copy behaviour and the note about rebinding names.
- Python 3.15 documentation, Mapping types: dict, frozendict: the list of missing methods, hashing,
|=and the inheritance note. - What’s new in Python 3.15, PEP 814: insertion order, modules that accept frozendict, the
isinstanceadvice and the JSON hooks. - json.load and json.JSONEncoder: the
object_pairs_hookandarray_hookparameters and encoder support. - functools.lru_cache, types.MappingProxyType and tomllib.load.
- dataclasses.dataclass, collections.abc.Hashable and object.__hash__.
- PEP 790, Python 3.15 release schedule.








No Comment! Be the first one.