Python 3.15 frozendict: Immutable Built-in Dicts Explained

You’ve been here before: you pass a dict to a function that mutates it, and two calls later your state is corrupted. Or you try to cache a function with a dict argument and @functools.lru_cache raises TypeError: unhashable type: 'dict'. You’ve reached for copy.deepcopy(), types.MappingProxyType, or a third-party frozendict package — each with its own gotchas. Python 3.15 just solved this permanently. PEP 814, accepted by the Steering Council in February 2026, introduces frozendict as a built-in immutable mapping type. It’s hashable when all keys and values are hashable, supports union operators (|, |=), and preserves insertion order. No inheritance from dict — it inherits from object directly, so no mutation loopholes through base-class methods.

This article covers construction patterns, when frozendict beats MappingProxyType, how hashing unlocks lru_cache with config dicts, and the pitfalls you need to watch for.

Construction and Basic Usage

frozendict mirrors the dict API for construction, so the mental model carries over immediately. It accepts keyword arguments, other mappings, or an iterable of key-value tuples:

from future import annotations

Keyword arguments

config = frozendict(host=”localhost”, port=8080, debug=False)

From an existing dict

defaults = frozendict({“timeout”: 30, “retries”: 3})

From an iterable of tuples

pairs = frozendict([(“color”, “blue”), (“size”, “large”)])

Empty

empty = frozendict()

Combined: mapping + kwargs (kwargs override)

merged = frozendict(defaults, port=9090, debug=True)

merged == frozendict({“timeout”: 30, “retries”: 3, “port”: 9090, “debug”: True})


All keys must be hashable, but values can be anything — even unhashable types like lists. The insertion order is preserved (just like regular `dict` since Python 3.7), and construction is O(n) with a shallow copy.

Iteration works through the standard `collections.abc.Mapping` protocol:

from collections.abc import Mapping

settings: frozendict[str, int] = frozendict(max_connections=10, pool_size=4)

assert isinstance(settings, Mapping)
assert list(settings.keys()) == ["max_connections", "pool_size"]
assert list(settings.values()) == [10, 4]
assert list(settings.items()) == [("max_connections", 10), ("pool_size", 4)]

Note the type annotation: frozendict[str, int] is the standard typing notation, available out of the box — no extra imports needed.

Union Operators and Merging

Python 3.9+ gave dict its | and |= merge operators via PEP 584. frozendict extends that same syntax with an important immutability guarantee: |= never mutates the left operand.

from future import annotations

base = frozendict(env=”production”, log_level=”WARNING”)
overrides = frozendict(log_level=”ERROR”, metrics=True)

| returns a NEW frozendict

combined = base | overrides

frozendict({“env”: “production”, “log_level”: “ERROR”, “metrics”: True})

| |= also returns a new frozendict; the original is untouched

ref = base
ref |= overrides
assert ref == combined
assert base == frozendict(env=”production”, log_level=”WARNING”) # unchanged!


You can also merge a regular `dict` into a `frozendict` — the result is always a `frozendict`:

from __future__ import annotations

result = frozendict(a=1) | {"b": 2}  # frozendict({"a": 1, "b": 2})

If keys overlap, the right operand wins — same semantics as dict.__or__.

Hashability: The lru_cache Unlock

Here’s where frozendict diverges from every other immutable-mapping approach in the standard library. Because frozendict is hashable (when all keys and values are hashable), it can serve as a dictionary key, a set element, or — crucially — a memoization cache argument:

from future import annotations
from functools import lru_cache
from frozendict import frozendict # built-in in 3.15+

@lru_cache(maxsize=128)
def query_database(params: frozendict[str, str]) -> list[dict]:
“””Cache results by query parameters. “””
# Simulate expensive DB query
return [{“id”: 1, “result”: “data”}]

This works now:

result = query_database(frozendict(table=”users”, region=”us-east”))

Different order, same content — same cache hit

result = query_database(frozendict(region=”us-east”, table=”users”))


The hash is order-independent — `frozendict(a=1, b=2)` and `frozendict(b=2, a=1)` produce the same hash and are equal. This makes frozendict ideal for configuration objects passed through caching layers:

from __future__ import annotations
from functools import lru_cache
from frozendict import frozendict


@lru_cache(maxsize=64)
def get_model_weights(config: frozendict[str, float]) -> tuple[float, ...]:
    """Load model weights keyed by hyperparameters. """
    key = "|".join(f"{k}={v}" for k, v in sorted(config.items()))
    # In real code: load from model registry using key
    return (0.5,) * 100


# Build configs without worrying about mutability
train_config = frozendict(lr=0.001, batch_size=32, epochs=10)
weights = get_model_weights(train_config)

Plain dict can’t participate here — @lru_cache raises TypeError immediately. You’d have to manually convert to tuple(sorted(d.items())), which is verbose and error-prone.

Common Mistakes

Mistake 1: Treating frozendict as a dict subclass

A common misconception is that frozendict inherits from dict. It doesn’t — it inherits directly from object. This is intentional: it prevents accidental mutation through inherited methods like dict.__setitem__ or dict.update. If you rely on isinstance(x, dict), it will return False for a frozendict:

from frozendict import frozendict

d = frozendict(x=1)

These are all False — frozendict is NOT a dict

assert isinstance(d, dict) == False
assert type(d) is dict == False

But it IS a Mapping

from collections.abc import Mapping
assert isinstance(d, Mapping) == True


**Fix:** If you need duck-typing compatibility, check against `collections.abc.Mapping` instead of `dict`. If you need to pass a frozendict to a function that explicitly checks `isinstance(x, dict)`, convert first: `dict(my_frozendict)`.

### Mistake 2: Assuming deep immutability

frozendict is **shallowly immutable**. If a value inside is a mutable object (list, dict, custom class), that inner object can still be mutated:

from frozendict import frozendict

data = frozendict(users=["alice"], metadata={"version": 1})

# You CANNOT do this — raises TypeError: 'frozendict' object does not support item assignment
# data["users"] = ["bob"]

# But you CAN mutate the inner list — frozendict doesn't protect nested objects
data["users"].append("bob")
# data is now frozendict({"users": ["alice", "bob"], "metadata": {"version": 1}})

# Same with nested dicts
data["metadata"]["version"] = 2  # silent mutation!

Fix: If you need deep immutability, use copy.deepcopy(). The copy.deepcopy() on a frozendict recursively freezes all nested mutable containers:

import copy
from frozendict import frozendict

original = frozendict(users=[“alice”], metadata={“version”: 1})
deep_copy = copy.deepcopy(original)

Nested mutations don’t propagate

deep_copy[“users”].append(“bob”)

original stays: frozendict({“users”: [“alice”], “metadata”: {“version”: 1}})


### Mistake 3: Using frozendict instead of MappingProxyType for the wrong job

`types.MappingProxyType` and `frozendict` solve different problems. MappingProxyType is a **view** into an existing mutable dict — if the underlying dict changes, the proxy sees it. frozendict is a **standalone copy** — it's a separate object with no reference to the source:

from types import MappingProxyType
from frozendict import frozendict

mutable = {"key": "value"}

proxy = MappingProxyType(mutable)
snapshot = frozendict(mutable)

# Changing the original affects the proxy but not the snapshot
mutable["key"] = "changed"

# proxy["key"] == "changed"  <- reflects mutation
assert snapshot["key"] == "value"  # unaffected

Fix: Use MappingProxyType when you want a read-only view of something that should be mutable (like class-level config). Use frozendict when you want an independent, hashable, truly immutable copy — especially for caching keys, set membership, or function defaults.

Migration from Third-Party Packages

Before Python 3.15, developers used the third-party frozendict package (by Matthew Schinckel). If you’re upgrading to 3.15+, the migration is straightforward — but not always zero-touch:

Before (Python 3.14 and earlier)

from frozendict import frozendict # third-party package on PyPI

config = frozendict(host=”localhost”, port=8080)

After (Python 3.15+)

frozendict is built-in — no import needed

config = frozendict(host=”localhost”, port=8080)


The PEP 814 implementation differs from the third-party package in a few key ways:

| Feature | Third-party `frozendict` | PEP 814 `frozendict` |
|---|---|---|
| Inheritance | Subclass of `dict` | Inherits from `object` |
| Hashing | Hashable by default | Hashable if all keys AND values are hashable |
| Insertion order | Order-preserving (since 2.x) | Order-preserving (dict-backed) |
| Union operators | `|` supported | `|` and `|=` supported (via PEP 584) |
| Typing | No native type hints | `frozendict[K, V]` supported natively |
| C API | N/A | `PyFrozenDict_New()`, `PyFrozenDict_Check()` |

The third-party package is not being discontinued, but the built-in version is the recommended path forward for Python 3.15+ codebases. If you maintain a library with a 3.10+ lower bound, use a compatibility shim:

from __future__ import annotations
from sys import version_info

if version_info >= (3, 15):
    frozendict = frozenset.__class__.__bases__[0].__bases__[0]  # just use built-in
    # Actually, simpler:
    from builtins import frozendict
else:
    from frozendict import frozendict  # type: ignore[assignment]

Or better yet, use a runtime check at import:

from future import annotations

try:
from builtins import frozendict # type: ignore[attr-defined, assignment]
except ImportError:
from frozendict import frozendict # type: ignore[assignment, import-not-found]


## Thread Safety

Once created, a frozendict is safe to share between threads without synchronization. Its shallow immutability means no lock is needed for reads. This makes frozendict especially valuable in free-threaded Python (Python 3.13+), where global interpreter lock elimination removes the traditional thread-safety guarantee:

from __future__ import annotations
import threading
from concurrent.futures import ThreadPoolExecutor
from frozendict import frozendict


# Shared immutable config — safe across threads
SHARED_CONFIG: frozendict[str, str] = frozendict(
    database_url="postgresql://localhost/db",
    max_retries="3",
    timeout="30",
)


def worker(task_id: int) -> str:
    # Every thread reads the same config, no locks needed
    url = SHARED_CONFIG["database_url"]
    return f"Thread {task_id} using {url}"


with ThreadPoolExecutor(max_workers=4) as pool:
    results = list(pool.map(worker, range(4)))
# ['Thread 0 using postgresql://localhost/db', ...]

Wrap-up

frozendict lands in Python 3.15 after a decade of community demand. It’s not a replacement for dict — regular dictionaries remain faster for write-heavy workloads. But for config objects, cache keys, function defaults, and cross-thread data sharing, frozendict eliminates an entire class of bugs at the type level rather than the documentation level.

Start by scanning your codebase for MappingProxyType usage and @lru_cache patterns that require tuple(sorted(...)) hacks. Those are the lowest-hanging fruit for frozendict adoption.

Next step: Check your Python version (python --version) and experiment with frozendict in a local project or the 3.15 alpha playground to see how it integrates with your existing code patterns.

References

  1. PEP 814 – Add frozendict built-in type
  2. Python News: frozendict joins the built-ins (March 2026)
  3. Python News: Packaging Council and PEP 803 (May 2026)
  4. PEP 584 – Add Union Operators To dict
  5. PEP 772 – Python Packaging Council
  6. frozendict on PyPI (third-party package)
  7. PEP 814 Discourse discussion thread
  8. Python 3.15 changelog (CPython 3.16.0a0 docs)

No comments yet. Be the first to leave a comment!

Leave a Comment

Your email address will not be published. Required fields are marked *