skip to content

What does @dataclass(frozen=True) actually prevent, and what does it not?

level: juniorimportance: must knowfreq 62%

answer

  1. Nothing here is deeply frozen
  2. The block lives in generated methods
  3. A specific AttributeError subclass is raised
  4. Mutable field contents stay mutable
  5. Use dataclasses.replace for a changed copy

basics

~20 s

Setting or deleting an attribute on a frozen dataclass instance raises dataclasses.FrozenInstanceError, because the decorator generates blocking setattr and delattr methods. It does not freeze what the fields point at: a list field can still be mutated in place.

solid answer

~40 s

`frozen=True` makes `@dataclass` generate a `__setattr__` and a `__delattr__` that raise `dataclasses.FrozenInstanceError` (a subclass of `AttributeError`), so `obj.field = value` and `del obj.field` fail after construction. That is the whole mechanism — it is a normal method on a normal class, not a new kind of object. The immutability is **shallow**: the name binding is frozen, the referenced object is not, so `obj.tags.append(...)` on a list field still works. Freeze the field types too — tuples, `frozenset`, other frozen dataclasses — if you want real value semantics. To "change" a frozen instance, build a new one with `dataclasses.replace(obj, field=new)`. A frozen and a non-frozen dataclass also cannot inherit from each other; that raises `TypeError` at class-creation time.

code

python · 16 lines
python
from dataclasses import dataclass, replace, FrozenInstanceError

@dataclass(frozen=True)
class Reading:
    name: str
    tags: list[str]

r = Reading("cpu", ["host:a"])
try:
    r.name = "mem"
except FrozenInstanceError as exc:
    print("blocked:", exc)

r.tags.append("host:b")
print(r.tags)
print(replace(r, name="mem"))

go deeper

for a junior

Be ready to say what happens when you assign to a field of a frozen dataclass instance, name the error, and show that a list field can still be appended to.

for a middle

Explain the mechanism: the decorator generates a setattr and a delattr that raise, so immutability is shallow and enforced at the class level. Know dataclasses.replace as the way to make a changed copy.

for a senior

Show judgement about field types — a frozen record holding mutable containers gives none of the sharing guarantees you claimed for it. Know the frozen/non-frozen inheritance restriction before designing a hierarchy around it.

for a principal

Own the API-shape argument: frozen value objects at module boundaries buy predictable equality, cacheability and no action-at-a-distance, at the cost of allocation churn and a hierarchy that must be frozen all the way down.

## What the decorator actually generates `@dataclass(frozen=True)` does not produce a special immutable object. It decorates an ordinary class and adds two ordinary methods: a `__setattr__` and a `__delattr__` that raise `dataclasses.FrozenInstanceError`. Everything else — the generated `__init__`, `__repr__`, `__eq__` — is exactly what a non-frozen dataclass gets. The generated `__init__` has to bypass its own block internally in order to populate the fields once; after that call returns, every assignment through the instance is refused. The error type matters. `dataclasses.FrozenInstanceError` inherits from `AttributeError`, so a broad `except AttributeError` swallows it, and duck-typing code that probes with `setattr()` inside a `try` will silently treat a frozen instance as "attribute not supported" rather than "deliberately immutable". The message reads `cannot assign to field 'name'`. One detail that surprises people: on an instance of exactly the frozen class, *any* assignment is refused, not just assignment to a declared field. `obj.typo = 1` raises the same `FrozenInstanceError` — the generated `__setattr__` refuses everything when `type(self)` is the frozen class itself, and consults the field names only for subclass instances. ## The immutability is shallow This is the point interviewers are really testing. `frozen=True` freezes the *binding between the attribute name and the object*, not the object on the other end of that binding: ```python from dataclasses import dataclass @dataclass(frozen=True) class Reading: name: str tags: list[str] r = Reading("cpu", ["host:a"]) r.name = "mem" # FrozenInstanceError r.tags.append("x") # works fine, the list is a normal list ``` So a frozen dataclass is only as immutable as its field *types*. If you want real value semantics, use immutable field types: `str`, `int`, `tuple`, `frozenset`, other frozen dataclasses. A `list` or `dict` field re-opens every door `frozen=True` was meant to close, including the safe-sharing and thread-safety arguments people use to justify it. ## Hashability comes with it With the default `eq=True`, adding `frozen=True` is also what makes the class hashable — the decorator generates a `__hash__` instead of setting it to `None`. That is why frozen dataclasses can be dictionary keys and set members while plain ones cannot. (The full rule set for `eq`, `frozen` and `unsafe_hash` is a topic in its own right.) ## Inheritance must agree Frozen-ness is not a per-class choice inside a hierarchy. Deriving a non-frozen dataclass from a frozen one raises `TypeError: cannot inherit non-frozen dataclass from a frozen one` at class-creation time, and the reverse combination fails too. A frozen base therefore commits the whole subtree. Teams discover this when they try to add a "just one mutable subclass" escape hatch. ## Changing a frozen value The idiomatic replacement for mutation is `dataclasses.replace(obj, field=value)`. It reads the current field values, overrides the ones you name, and calls the class's normal construction path, so validation and derived fields run exactly as they do for a fresh object. Fields excluded from `__init__` cannot be passed to it. From Python 3.13 there is also a generic `copy.replace(obj, field=value)` that works on frozen dataclass instances and on any other type supporting the same protocol. The read side has two companions worth knowing. `dataclasses.fields(obj)` returns a tuple of `Field` objects describing the declared fields — name, type, default, flags — which is how generic serializers walk a dataclass. `dataclasses.asdict(obj)` returns a dict, converting nested dataclasses recursively and deep-copying everything else, which means the list in the output is a *copy* rather than the instance's own list; `dataclasses.astuple(obj)` does the same into a tuple. ## When to reach for it Use `frozen=True` for values that travel: configuration records, coordinates, identifiers, message payloads, cache keys. You get equality and hashing that match the data, safe sharing between components, and no action-at-a-distance when an object is handed to code you do not control. The costs are real but small — every "edit" allocates a new instance, and the Python-level `__setattr__` makes construction measurably slower than a plain dataclass, which matters only in hot allocation loops. What you do **not** get: deep immutability, a security boundary (the block is a normal method, and a determined caller can go around it), thread safety when a field holds a mutable container, or any kind of interning — two equal frozen instances remain two distinct objects.

  • How do you produce a modified copy of a frozen dataclass instance?
    Call `dataclasses.replace(obj, field=new_value)`. It collects the instance's current field values, overrides the ones you name, and runs the class's normal construction path, so any validation happens again. Fields excluded from the generated `__init__` cannot be passed. Since Python 3.13 the generic `copy.replace(obj, field=new_value)` does the same job for dataclasses and for any other type that supports it.
  • What does @dataclass(kw_only=True) change about the generated __init__?
    It makes every field keyword-only in the generated `__init__`, so callers must write `Record(name=..., value=...)`. Because there are no positional slots to order, a field with a default may then precede one without a default and the usual ordering error disappears. It also stops a new base-class field from silently shifting existing positional call sites. Added in Python 3.10; `dataclasses.KW_ONLY` marks a cut-point so only the fields after it become keyword-only.
  • Does frozen=True make a dataclass safe to share between threads?
    Only if the field values are themselves immutable. Frozen prevents rebinding an attribute, which removes one class of race, but a list or dict field is still shared mutable state and needs the same protection it would need anywhere else. A frozen dataclass of `str`, `int`, `tuple` and other frozen dataclasses is genuinely safe to publish; one holding a mutable container is not.

It is a label glued to a jar, not a sealed jar: you cannot swap the label onto a different jar, but nothing stops someone reaching in and stirring the contents.

saying these in an interview costs you the question

  • Claims frozen=True makes the object deeply immutable
  • Says a frozen dataclass cannot hold a list at all
  • Expects a TypeError rather than an AttributeError subclass
  • Rebuilds instances by hand instead of using dataclasses.replace
  • Assumes a mutable subclass of a frozen dataclass is allowed
  • Calls frozen=True a security or thread-safety guarantee

context