We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Data Modelling and Invariants step 15 of 25
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
Mappingbecomes atupleof(key, frozen_value)pairs, sorted by key (so two equal dicts with different insertion order freeze identically); -
a
setorfrozensetbecomes afrozensetof frozen items; -
a
listortuplebecomes atupleof 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.
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.