Skip to content

← Data Modelling and Invariants step 15 of 25

Medium Primitives

frozen=True: what it buys, what it costs, and what it does not do

Frozen value objects are the highest-leverage habit available for concurrent and cached code, for a reason that fits on one line: you cannot race on something nobody can write. No lock, no defensive copy, no “who mutated this” incident. Three things about frozen=True are routinely misunderstood.

1. It is shallow

frozen=True blocks __setattr__ on the instance. It says nothing about what the fields point at:

@dataclass(frozen=True)
class Config:
    opts: dict[str, str]

c = Config({"debug": "0"})
c.opts = {}            # FrozenInstanceError
c.opts["debug"] = "1"  # works fine. shared, mutable, and now wrong.

mypy catches the first line (a read-only property) and does not catch the second. The static fix is not a decorator flag — it is the annotation: declare the field Mapping[str, str] rather than dict[str, str] and the mutating call becomes an error, because Mapping has no __setitem__.

2. It is not free

Measured on CPython 3.14.6, three int fields, min-of-3: plain dataclass construction 43.5 ns, frozen 150.7 ns — a 3.4× cost. The cause is structural: because the class blocks __setattr__, the generated __init__ cannot use it, so it calls object.__setattr__(self, name, value) per field. The docs’ phrase “tiny performance penalty” is misleading; at a million constructions per request that is a real number. It is usually still worth it — just know what you are buying.

3. Frozen-ness is hierarchy-locked

A frozen dataclass cannot inherit from a non-frozen one, and vice versa — TypeError at class creation. Decide at the root of the hierarchy.

And one trap for the road: FrozenInstanceError subclasses AttributeError. A broad except AttributeError: — the kind people write around getattr fallbacks — swallows every frozen-mutation bug in the block.

Frozen is not hashable-enough

eq=True, frozen=True generates __hash__, so a frozen dataclass looks hashable. But the generated hash is hash((self.a, self.b)) — if any field holds a list, hash() raises TypeError: unhashable type: 'list' at the moment you use it as a dict key, typically deep inside an lru_cache. Hashable-in-principle, unhashable-in-fact.


Your task

Two parts.

deep_freeze(obj: object) -> Hashable converts a nested structure into a fully hashable immutable equivalent:

  • a Mapping becomes a tuple of (key, frozen_value) pairs, sorted by key (so two equal dicts with different insertion order freeze identically);
  • a set or frozenset becomes a frozenset of frozen items;
  • a list or tuple becomes a tuple of frozen items;
  • a dataclass instance becomes (ClassName, ((field_name, frozen_value), ...));
  • anything else is returned unchanged.

Check Mapping before the sequence branch and put the dataclass branch after them, so a NamedTuple still freezes as a tuple.

CacheKey must be frozen=True, slots=True with name: str and payload: Hashable.

Then solve(config) returns:

key value
"frozen" deep_freeze(config)
"raw_hashable" can the raw input be hashed?
"frozen_hashable" can the frozen form be hashed?
"raw_key_hashable" can a CacheKey wrapping the raw input be hashed?
"key_hash_stable" two CacheKeys built from independent freezes hash equal
"key_equal" …and compare equal
"mutation_error" the exception name from setattr(key, "name", ...)

The returned "frozen" value is compared by container type: a tuple where the grader expects a tuple, a frozenset where it expects a frozenset. Returning lists fails.

Two details that carry the lesson. raw_key_hashable requires cast(Hashable, config) to even construct — mypy correctly refuses dict where Hashable is expected, and the cast is you overriding it, which is exactly how this bug reaches production. And in _mutation_error, FrozenInstanceError must be caught before AttributeError, because it is a subclass.