In a frozen dataclass, how does __post_init__ set a derived or normalized field?
answer
- Frozen blocks even the constructor
- Call the base implementation directly
- Legitimate only during construction
- Pair it with a non-init field
- Normalize what equality compares
basics
~10 sAssign through object.setattr(self, "name", value). A frozen dataclass installs a setattr that raises FrozenInstanceError, and post_init is not exempt, so a plain self.name = value fails even during construction.
solid answer
~40 s`@dataclass(frozen=True)` gives the class a `__setattr__` that raises `dataclasses.FrozenInstanceError` on every assignment, including the ones the generated `__init__` needs — which is why that constructor sets fields with `object.__setattr__` and why your `__post_init__` must do the same. Declare the derived attribute as `field(init=False)` so it is not a constructor parameter, then call `object.__setattr__(self, "canonical_format", value)` in the hook; normalizing an existing field works identically by writing back to its own name. The escape hatch is legitimate because it happens inside construction, before the instance is visible to anyone, and it is the pattern the standard library itself uses. Do it before the object is hashed or stored: normalizing the field that participates in `__eq__` is exactly what makes two spellings of the same value compare and hash alike.
code
python · 14 linesfrom dataclasses import dataclass, field
@dataclass(frozen=True)
class ConversionJob:
doc_id: str
output_format: str
canonical_format: str = field(init=False)
def __post_init__(self) -> None:
object.__setattr__(
self, "canonical_format", self.output_format.strip().lower()
)
print(ConversionJob("doc-27", " PDF/A "))go deeper
Remember the mechanical rule: inside a frozen dataclass you cannot write self.attr, even in post_init, and the way through is object.setattr with the attribute name as a string.
Explain why: frozen generates a setattr that raises FrozenInstanceError, the generated constructor itself assigns via object.setattr, and a derived attribute is declared field(init=False) so callers cannot supply it.
Show the consequences you have hit in production: normalization must land on the field that eq and hash use, the escape hatch is confined to construction, and copy or pickle restores state without re-running the hook.
Take a position on where normalization lives at all — inside the value type, or in the factories that adapt each input source — and on how much bending of an immutability guarantee a codebase tolerates before the guarantee stops meaning anything in review.
## Why the ordinary assignment fails `@dataclass(frozen=True)` does not make the object magically immutable at the C level; it generates a `__setattr__` (and a `__delattr__`) that unconditionally raise `dataclasses.FrozenInstanceError`, a subclass of `AttributeError`. That guard has no notion of "still constructing", so it fires inside `__post_init__` exactly as it would from outside: ```pycon >>> from dataclasses import FrozenInstanceError, dataclass >>> @dataclass(frozen=True) ... class Retry: ... count: int ... def __post_init__(self): ... self.count = abs(self.count) ... >>> Retry(-1) Traceback (most recent call last): ... dataclasses.FrozenInstanceError: cannot assign to field 'count' ``` The generated `__init__` has the same problem and solves it the same way: for a frozen class the decorator emits `object.__setattr__(self, 'count', count)` rather than a plain assignment, calling the base implementation directly and stepping around the class's own override. Your hook is entitled to the identical move. ## The pattern ```python from dataclasses import dataclass, field @dataclass(frozen=True) class ConversionJob: doc_id: str output_format: str canonical_format: str = field(init=False) def __post_init__(self) -> None: object.__setattr__( self, "canonical_format", self.output_format.strip().lower() ) ``` Two halves. `field(init=False)` keeps the derived name out of the constructor signature, so callers cannot supply it and it cannot disagree with the inputs. `object.__setattr__` writes it once, during construction, while the instance is still private to the constructor — the reason this is a legitimate escape hatch rather than a violation of the contract. After `__init__` returns, the object really is frozen for everyone including its own methods. ## Normalize the field that identity depends on Where this earns its place is equality and hashing. A frozen dataclass with `eq=True` gets a generated `__hash__` derived from the tuple of its compared fields, so it can be a dict key or a set member. In a document-conversion queue that dedupes pending jobs in a set, the format name arrives in a locale-dependent spelling — `"PDF/A"` from one client, `"pdf/a"` from another, sometimes with stray whitespace — and two records that mean the same job hash differently and both get converted. Normalizing at construction fixes it, but only if you normalize *the field that is compared*: writing a cleaned copy into a second attribute while leaving the raw one in place keeps the raw spelling in `__eq__` and the duplicates keep coming. Either write back to the same name, or keep the raw input out of comparison altogether — as a `field(compare=False)`, or as an `InitVar` that is consumed and never stored. That is the difference between a bug that vanishes and one that hides until a long end-to-end run — a 27-minute suite, say — trips over it at the wrong layer. ## Caveats worth stating out loud **It is not a general licence.** `object.__setattr__` from anywhere other than construction defeats the guarantee the type advertises: another thread may already hold the object, and anything that hashed it now has a stale bucket. Confine it to `__post_init__`. **Inheritance rules still apply.** A frozen dataclass cannot inherit from a non-frozen one, or vice versa; the decorator raises `TypeError` at class creation. Adding a frozen base to fix a mutation bug can therefore ripple further than expected. **Restoration paths skip it.** `copy.deepcopy` and `pickle` rebuild the instance from its stored state without calling `__init__`, so the normalization does not re-run — which is harmless when the stored state is already normalized, and a trap when you were relying on the hook to repair legacy data. **Consider not needing the hook at all.** A `classmethod` factory that cleans its arguments and then calls the constructor with values already correct is often clearer than an escape hatch: `ConversionJob.from_request(...)` normalizes, `ConversionJob(...)` stays a dumb record, and no one has to justify a call to `object.__setattr__` in review. Reach for `__post_init__` plus `object.__setattr__` when the type must be constructible directly — because it is deserialized, or built by generic code that only knows the field names — and for the cross-field checks a factory would duplicate. ## When the derived value is expensive Not every derived value belongs in a field. `functools.cached_property` works on a frozen dataclass — it stores its result straight into the instance `__dict__` rather than going through `__setattr__`, so the frozen guard never sees it — and it computes lazily, once, only if something asks. The tradeoff is that the cached value is not a field: it is absent from the repr, from equality, from `dataclasses.fields()` and from `dataclasses.asdict()`, and it needs an instance `__dict__` to live in. That makes the choice fairly clean. If the derived value is part of what the record *is* — the thing equality should compare, the thing a serializer should emit — derive it eagerly in `__post_init__` with `object.__setattr__` into a `field(init=False)`. If it is a convenience computed from the fields, expensive, and not part of the value's identity, make it a cached property and leave the record's field set alone.
- Why is calling object.__setattr__ inside __post_init__ not simply cheating the frozen guarantee?Because it happens while the instance is still inside its own constructor and no other code holds a reference: nothing has compared it, hashed it or stored it yet, so the value it settles on is the only value anyone will ever observe. The generated `__init__` of a frozen dataclass uses the same call to assign its fields. Using it after construction is the abuse — it can invalidate a hash already computed by a set or dict.
- Two frozen job records normalize into a second attribute but still fail to deduplicate in a set. Why?Because equality and the generated `__hash__` are built from the compared fields, and the raw, unnormalized field is still one of them. Two records with `"PDF/A"` and `"pdf/a"` differ there however clean the derived attribute is. Normalize in place by writing back to the same field, or keep the raw value out of comparison — `field(compare=False)`, or an `InitVar` that is consumed by the hook and never stored.
- When would you use a classmethod factory instead of normalizing inside __post_init__?When the cleaning belongs to a particular *source* of data rather than to the type: `from_request` or `from_row` can coerce, rename and default its inputs, leaving the dataclass a plain record whose constructor takes already-valid values. Keep the hook for invariants that must hold no matter who builds the object, and for classes that generic code constructs directly from field names.
saying these in an interview costs you the question
- Assigns with self.attr inside a frozen __post_init__
- Thinks frozen only blocks assignment after construction
- Calls object.__setattr__ from ordinary methods too
- Believes frozen makes the whole object deeply immutable
- Normalizes a copy while equality still compares the raw field
- Expects deepcopy or unpickling to re-run the normalization