frozendict is a new built-in type in Python 3.15: a dictionary that can’t be changed after you create it. You make one just like a dict, for example frozendict(host="localhost", port=5432), and read from it the same way, but any attempt to add, change or delete a key raises an error. Because it can’t change, a frozendict is hashable (as long as its keys and values are), so you can use it as a dictionary key or put it in a set, which a normal dict can never do.
Python 3.15.0 was released on 9 October 2026, and frozendict (PEP 814) is one of its headline features alongside lazy imports. I ran every example below on Python 3.15.0rc3, the final release candidate, installed with uv python install 3.15, and pasted the real output under each one. The behaviour matches the What’s New in Python 3.15 documentation for the final release.
Why Python added frozendict
Python has always had an immutable version of list (tuple) and of set (frozenset), but not of dict. Developers worked around that with types.MappingProxyType, with third-party packages, or by converting dicts to sorted tuples of pairs whenever they needed something hashable. None of those options were built in and obvious.
An immutable dictionary is useful whenever data should not change after it’s created:
- Configuration that’s loaded once and read everywhere.
- Default arguments for functions, where a mutable
{} default is a classic bug.
- Cache keys made of several named values, such as
(city, unit) lookups.
- Constants shared between modules, where an accidental write would cause a hard-to-find bug.
- Thread-safe sharing, because nobody can mutate an object that can’t be mutated.
How to create a frozendict
You don’t import anything; frozendict is a built-in, just like dict. It accepts the same kinds of input as dict(): keyword arguments, an existing mapping, or an iterable of key–value pairs.
a = frozendict(x=1, y=2)
print(a)
print(frozendict({"host": "localhost", "port": 5432}))
print(frozendict([("a", 1), ("b", 2)]))
frozendict({'x': 1, 'y': 2})
frozendict({'host': 'localhost', 'port': 5432})
frozendict({'a': 1, 'b': 2})
frozendict.fromkeys() works too, which is handy for building a fixed set of counters or flags:
print(frozendict.fromkeys(["a", "b"], 0))
frozendict({'a': 0, 'b': 0})
There’s no literal syntax like {...} for a frozendict. You always call frozendict(...).
What you can’t do: frozendict is read-only
Every operation that would change the mapping fails. I tried the usual suspects:
for stmt in ["a['z'] = 3", "del a['x']", "a.update(z=3)", "a.pop('x')", "a.clear()"]:
try:
exec(stmt, {"a": a})
except (TypeError, AttributeError) as e:
print(stmt, "->", type(e).__name__ + ":", e)
a['z'] = 3 -> TypeError: 'frozendict' object does not support item assignment
del a['x'] -> TypeError: 'frozendict' object does not support item deletion
a.update(z=3) -> AttributeError: 'frozendict' object has no attribute 'update'
a.pop('x') -> AttributeError: 'frozendict' object has no attribute 'pop'
a.clear() -> AttributeError: 'frozendict' object has no attribute 'clear'
Notice the two kinds of error. Assigning or deleting with square brackets raises TypeError. Mutating methods such as update(), pop(), clear() and setdefault() simply don’t exist, so you get AttributeError. In fact, the public methods on frozendict in my 3.15 build were just these:
['copy', 'fromkeys', 'get', 'items', 'keys', 'values']
What you can do: reading works like a dict
All the read operations you already know from dictionaries work the same way:
print(a["x"], a.get("z", "missing"), list(a.keys()), list(a.items()), len(a), "x" in a)
1 missing ['x', 'y'] [('x', 1), ('y', 2)] 2 True
You can loop over it, unpack it with **, pass it to dict() and use comprehensions on it. If you’re used to looping with .items(), my Python list comprehension examples all work on a frozendict as the source, as long as you’re building a new object rather than editing the old one.
Why frozendict is hashable (and why that matters)
A value is hashable if Python can compute a fixed number for it with hash(). Only hashable values can be dictionary keys or set members. A normal dict isn’t hashable because it can change; if it changed after being used as a key, the lookup would break.
frozendict can’t change, so Python lets you hash it. The documentation also notes that a frozendict preserves insertion order, but comparison doesn’t take order into account:
b = frozendict(y=2, x=1)
print(hash(a) == hash(b), a == b, a == {"x": 1, "y": 2})
True True True
So two frozendicts with the same pairs in a different order are equal and hash the same, and a frozendict compares equal to a normal dict with the same contents. The order is still kept for display and iteration:
print(frozendict(b=1, a=2), list(frozendict(b=1, a=2)))
frozendict({'b': 1, 'a': 2}) ['b', 'a']
Using a frozendict as a dictionary key
This is the feature that makes it genuinely new. Here’s a tiny cache keyed by a set of named options:
cache = {frozendict(city="Leeds", unit="C"): 14}
print(cache[frozendict(unit="C", city="Leeds")])
14
The lookup key is written in a different order, but it still finds the entry. Before 3.15, you’d typically write tuple(sorted(options.items())) to get the same effect, which is harder to read and easy to get wrong.
Removing duplicate dicts with a set
Because frozendicts can live in a set, you can de-duplicate records:
print(len({a, b}))
1
a and b are equal, so the set keeps just one. For de-duplicating lists of simple values, the techniques in my guide to removing duplicates from a list in Python still apply; frozendict simply extends the same idea to records.
The catch: values must be hashable too
A frozendict is only hashable if all its keys and values are. A list inside it breaks hashing, even though the frozendict itself still can’t be modified:
c = frozendict(tags=["php", "sql"])
try:
hash(c)
except TypeError as e:
print("TypeError:", e)
TypeError: unhashable type: 'list'
Use a tuple (tags=("php", "sql")) or a frozenset if you need the whole thing to be hashable.
frozendict is not a subclass of dict
This is the gotcha most likely to bite existing code. The documentation says a frozendict “is not a subclass of dict; it inherits directly from object.” I checked:
from collections.abc import Mapping
print(isinstance(a, dict), isinstance(a, Mapping), type(a).__mro__)
False True (<class 'frozendict'>, <class 'object'>)
Any function that checks isinstance(arg, dict) will reject a frozendict. The documentation recommends two fixes:
isinstance(arg, (dict, frozendict)) if you want exactly those two types.
isinstance(arg, collections.abc.Mapping) if you want to accept any read-only mapping, including MappingProxyType.
The second option is usually the better design: most functions only need to read keys and values, so they should ask for a Mapping, not a dict.
Creating changed copies: | and dict()
Since you can’t edit a frozendict, you create a new one with the changes. The merge operator | works and returns a frozendict when the left side is one:
print(a | {"z": 3}, type(a | {"z": 3}).__name__)
print(type({"a": 1} | frozendict(b=2)).__name__)
frozendict({'x': 1, 'y': 2, 'z': 3}) frozendict
dict
The result type follows the left-hand operand: frozendict | dict gives a frozendict, while dict | frozendict gives a normal dict. To change a value, build a dict, adjust it and freeze it again:
print(dict(a), frozendict(dict(a, x=10)))
{'x': 1, 'y': 2} frozendict({'x': 10, 'y': 2})
copy() is cheap: because the object can never change, a.copy() is a returned True in my test, and copy.copy(a) is a did too. There’s no point duplicating something immutable.
JSON, pickle and the standard library
According to the 3.15 documentation, these standard library modules were updated to accept frozendict: copy, decimal, json, marshal, plistlib (serialisation only), pickle, pprint and xml.etree.ElementTree. JSON and pickle round-trips worked in my test:
import json, copy, pickle
print(json.dumps(a), copy.copy(a) is a, pickle.loads(pickle.dumps(a)) == a)
{"x": 1, "y": 2} True True
Note that json.loads() still returns ordinary dicts when you read JSON back. If you want a frozen config from a JSON file, wrap the result: frozendict(json.load(f)). For writing JSON nicely formatted, the options in my post on writing JSON to a file with pretty print work with frozendicts unchanged.
Nested data: frozendict is shallow
Freezing the outer mapping doesn’t freeze what’s inside. If you want nested config to be read-only, freeze each level:
cfg = frozendict(db=frozendict(host="localhost"), debug=False)
try:
cfg["db"]["host"] = "x"
except TypeError as e:
print("TypeError:", e)
TypeError: 'frozendict' object does not support item assignment
If db had been a normal dict, cfg["db"]["host"] = "x" would have worked silently.
A recursive freeze() helper for JSON config
Real config is usually loaded from JSON and nested several levels deep. This small helper converts every dict into a frozendict and every list into a tuple, so the whole structure becomes read-only and hashable:
import json
def freeze(value):
if isinstance(value, dict):
return frozendict({k: freeze(v) for k, v in value.items()})
if isinstance(value, list):
return tuple(freeze(v) for v in value)
return value
raw = json.loads('{"site": "mfaysal.com", "db": {"host": "localhost", "port": 3306}, "admins": ["mahir"]}')
CONFIG = freeze(raw)
print(CONFIG)
print(CONFIG["db"]["port"], hash(CONFIG) == hash(freeze(raw)))
try:
CONFIG["db"]["port"] = 5432
except TypeError as e:
print("TypeError:", e)
frozendict({'site': 'mfaysal.com', 'db': frozendict({'host': 'localhost', 'port': 3306}), 'admins': ('mahir',)})
3306 True
TypeError: 'frozendict' object does not support item assignment
Load the file once at startup, freeze it, and import CONFIG wherever you need it. Any code that tries to change a setting at runtime fails loudly instead of quietly affecting the rest of the app.
A safe default argument
A mutable default argument such as def connect(options={}) is one of Python’s best-known traps, because the same dict is shared between every call. A frozendict default is safe because nothing can change it:
def connect(options=frozendict(timeout=5)):
return options["timeout"]
print(connect())
5
frozendict vs MappingProxyType vs dict
Before 3.15, types.MappingProxyType was the built-in way to get a read-only view. It’s still useful, but it’s different in an important way: it’s a view of an underlying dict, not a frozen copy.
import types
inner = {"x": 1}
mp = types.MappingProxyType(inner)
inner["x"] = 99
print(mp["x"])
f = frozendict(inner)
inner["x"] = 1
print(f["x"])
99
99
The proxy showed the change to inner, because it only wraps it. The frozendict kept the value it had when it was created (99), even after inner changed back to 1. A MappingProxyType also isn’t hashable. Here’s how the three compare:
dict: mutable, not hashable, available in every version.
types.MappingProxyType: read-only view; changes to the underlying dict show through; not hashable; available since Python 3.3.
frozendict: a frozen copy; nothing can change it; hashable when its contents are; new in Python 3.15.
Supporting older Python versions
frozendict doesn’t exist before 3.15, so code that must also run on 3.14 or earlier needs a fallback. A simple approach is to fall back to a read-only proxy, accepting that you lose hashability on older versions:
import sys, types
try:
frozendict
except NameError:
frozendict = types.MappingProxyType
print(sys.version_info[:2] >= (3, 15))
True
On 3.15 the try succeeds and you get the real built-in. On older versions you get a read-only wrapper. Don’t rely on hashing in that case, and remember that MappingProxyType needs a dict argument rather than keyword arguments.
Should you switch your dicts to frozendict?
Not everywhere. Normal dicts are still the right choice for data you build up and change. Reach for frozendict when:
- A value is meant to be constant, and an accidental write would be a bug.
- You need a mapping as a dict key or set member.
- You want a safe default argument or a shared object between threads.
Keep in mind the deployment side too: Python 3.15 is brand new, so check that your hosting platform and libraries support it before you rely on new built-ins in production.
FAQ
What is frozendict in Python?
frozendict is a built-in immutable dictionary type added in Python 3.15 (PEP 814). It behaves like a read-only dict: you can read keys and values, but you can’t add, change or delete entries after creating it.
Which Python version has frozendict?
Python 3.15 and later. It was released on 9 October 2026. On Python 3.14 and earlier, the name frozendict doesn’t exist unless you install a third-party package or define a fallback.
Is frozendict hashable?
Yes, as long as all its keys and values are hashable. That means you can use it as a dictionary key or put it in a set. A frozendict containing a list raises TypeError: unhashable type: 'list' when hashed.
Is frozendict a subclass of dict?
No. It inherits directly from object, so isinstance(x, dict) returns False. Check for collections.abc.Mapping or (dict, frozendict) instead.
How do I add a key to a frozendict?
You can’t change it in place, but you can create a new one with the merge operator: new = old | {"key": "value"}. When the left side is a frozendict, the result is also a frozendict.
What is the difference between frozendict and MappingProxyType?
MappingProxyType is a read-only view of an existing dict, so changes to that dict show through, and it isn’t hashable. A frozendict is an independent frozen copy that never changes and is hashable when its contents are.
Does frozendict keep insertion order?
Yes. It preserves insertion order for display and iteration, but equality ignores order, so frozendict(x=1, y=2) == frozendict(y=2, x=1) is True.