Blog / Coding tips

Python frozendict: The New Immutable Dictionary in Python 3.15 (Tested)

Cover image for Python frozendict: The New Immutable Dictionary in Python 3.15 (Tested)

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:

  1. A value is meant to be constant, and an accidental write would be a bug.
  2. You need a mapping as a dict key or set member.
  3. 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.

// note

How to read this note.

This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.

If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.

Related posts

Python enumerate(): How to Start at 1 (and 12 Tested Examples)
Coding tips

Python enumerate(): How to Start at 1 (and 12 Tested Examples)

enumerate(items, start=1) gives you a counter that starts at 1 alongside each item. Tested examples for lists, dicts, zip, reversed order, file line numbers and comprehensions, plus why it beats range(len()) and the list.index() trap.

October 11, 2026 · 11 min read · 1 view

Python Lazy Imports: How the New lazy Keyword Works in Python 3.15
Coding tips

Python Lazy Imports: How the New lazy Keyword Works in Python 3.15

Python 3.15 adds the lazy soft keyword: lazy import json loads the module only when you first use it. Tested examples of the startup saving, where lazy isn’t allowed, how errors appear, -X lazy_imports=all, filters and __lazy_modules__.

October 12, 2026 · 11 min read · 0 views