When does __post_init__ run in a Python dataclass, and what is it for?
answer
- The generated constructor's last act
- Where construction-time code goes
- Emitted only if you define it
- Skipped when __init__ is not generated
- Takes InitVar values, not stored fields
basics
~10 s@dataclass generates an init that assigns every field, and if the class defines post_init that generated constructor calls it as its final statement. It is the hook for validating arguments and computing derived values.
solid answer
~40 s`@dataclass` writes the constructor for you, so there is no obvious seam for the code a constructor normally runs besides assignment. `__post_init__` is that seam: the generated `__init__` assigns every field in declaration order, then calls `self.__post_init__(...)` last, passing one positional argument per `InitVar` pseudo-field. Because every field is already populated, the method can check invariants and raise (`ValueError` for a bad combination), and it can fill in a field declared `field(init=False)` from the other values. Two conditions matter. The call is emitted only if the method exists when the decorator runs, and only inside a *generated* constructor: with `@dataclass(init=False)`, or a hand-written `__init__`, nothing calls it. Inheritance is ordinary attribute lookup, so exactly one `__post_init__` runs and a subclass that overrides it must call `super().__post_init__()` itself.
code
python · 14 linesfrom dataclasses import dataclass, field
@dataclass
class Rect:
width: float
height: float
area: float = field(init=False)
def __post_init__(self) -> None:
if self.width <= 0 or self.height <= 0:
raise ValueError("sides must be positive")
self.area = self.width * self.height
print(Rect(3.0, 4.0))go deeper
Recall the one-liner: the generated constructor assigns the fields and then calls post_init, which is where validation and computed attributes go. Be able to write a small dataclass that raises ValueError on a bad argument.
Explain the mechanics: the call is emitted only if the method exists when the decorator runs, only inside a generated init, and it receives InitVar values rather than the stored fields. Know that a field(init=False) left unassigned raises AttributeError on first access.
Show where the hook is the wrong tool: it fires once, so it cannot hold an invariant over a mutable instance, and copy or pickle restores state without it. Be ready to say when you would reach for a frozen value type or a factory classmethod instead.
Own the convention across a codebase: whether construction-time validation belongs in the value type at all, or at the system boundary where bad input arrives, and what it costs when every dataclass raises from its constructor deep inside a call stack.
## What the decorator actually writes When `@dataclass` runs, it inspects the class's annotations, builds a descriptor-free `Field` record for each annotated name, and compiles a genuine `__init__` (plus `__repr__` and `__eq__`, unless switched off) from generated source. The body of that constructor does exactly one kind of thing: it assigns each field, in declaration order, from its parameter, from its default, or from a `default_factory` call. That is all. Since you did not write the constructor, you have nowhere to put the other two jobs a constructor normally does — checking that the arguments make sense together, and deriving values from them. `__post_init__` is the seam the module leaves for both. ## The exact contract If the class has an attribute named `__post_init__` **at the moment the decorator runs**, the generated `__init__` ends with a call to `self.__post_init__(...)`. The call happens after every field has been assigned, so the instance is fully populated by then. Its parameters are `self` plus one positional parameter per `InitVar` pseudo-field, in declaration order; regular fields are *not* passed, because they are already attributes. The return value is thrown away. Beyond that there is no magic: it is an ordinary method, and an exception raised inside it propagates straight out of the constructor, so a failed object never escapes to the caller. ```python from dataclasses import dataclass, field @dataclass class Rect: width: float height: float area: float = field(init=False) def __post_init__(self) -> None: if self.width <= 0 or self.height <= 0: raise ValueError("sides must be positive") self.area = self.width * self.height ``` ## Two conditions people miss **It must exist at decoration time.** The constructor's source is compiled once, when the decorator runs; the `self.__post_init__()` line is emitted only if the attribute is found then. Attaching the method to the class afterwards has no effect at all — instances are built by a constructor that simply does not contain the call. **It only lives in a generated constructor.** `@dataclass(init=False)` tells the decorator not to write `__init__`, and a hand-written `__init__` in the class body is likewise left alone. In both cases nothing calls `__post_init__`, and the usual symptom is a derived attribute that is missing or stale rather than a loud error. If you write your own constructor and still want the hook, call it explicitly. By the same logic, `copy.deepcopy` and `pickle` do **not** re-run it: both restore instance state directly rather than calling `__init__`, so any normalization that lives only in `__post_init__` is simply carried over from the original object, not recomputed. ## The two idiomatic uses **Validation.** Raise `ValueError` (or `TypeError` for a wrong kind of argument) on a combination the type cannot represent — a negative size, an end before a start, a currency mismatch. This is the single construction-time gate a dataclass gets. **Derived fields.** Declare the computed attribute with `field(init=False)` so it is not a constructor parameter, then assign it in `__post_init__`. A field declared `init=False` with no default is never assigned by the generated `__init__`; if `__post_init__` forgets it, the attribute does not exist and even `repr()` fails with `AttributeError: 'Rect' object has no attribute 'area'`. That crash is a feature — it is loud, and it happens on the first use. ## Inheritance Nothing chains automatically. `self.__post_init__(...)` is one attribute lookup, so the most-derived definition wins and the base's version runs only if the subclass calls `super().__post_init__()`. Because the subclass's generated `__init__` assigns the inherited fields as well as its own, the base's checks are silently skipped when that call is forgotten — a common bug in a small hierarchy of value types. ## What it is not It is not a replacement `__init__`: it cannot change the constructor's signature, cannot decide *which* fields exist, and cannot return a different object. It is not a validation framework either — it fires exactly once, at construction, and a plain dataclass stays mutable afterwards, so a later attribute assignment sails past every check it performs. And on a frozen dataclass, `self.attr = value` inside `__post_init__` raises `dataclasses.FrozenInstanceError` like any other assignment; the escape hatch there is `object.__setattr__`. ## What the hook can already see Every field is assigned before the call, defaults included, and that covers the less obvious cases. A field declared `field(init=False, default_factory=list)` is still populated by the generated constructor — `init=False` removes it from the parameter list, not from the assignment — so `__post_init__` can append to the list it finds rather than creating one. A field declared `init=False` with no default at all is the only gap: nothing assigns it, and the hook must, or the attribute simply does not exist. It is also worth remembering that this is a per-instantiation method call, so anything expensive inside it — reading a file, compiling a pattern, hitting a network — is paid on every construction, including the constructions a test suite performs thousands of times. Keep the hook to cheap checks and arithmetic, and push real work into a factory the caller invokes deliberately.
- Why does adding a __post_init__ method to a dataclass after the decorator has run change nothing?`@dataclass` compiles the constructor from generated source at decoration time and emits the `self.__post_init__()` line only if the attribute exists then. Attaching the method later leaves that already-compiled constructor untouched, so instances are built without ever calling it. The same reasoning explains why `@dataclass(init=False)` disables the hook: there is no generated constructor to carry the call.
- A base dataclass and its subclass both define __post_init__. Which of them runs?Only the subclass's, because the generated constructor performs a single ordinary attribute lookup for `__post_init__` and the most-derived definition wins. The base's version runs only if the subclass explicitly calls `super().__post_init__()`. Since the subclass constructor assigns the inherited fields too, forgetting that call silently drops the base class's validation.
- Does copying or unpickling a dataclass instance re-run __post_init__?No. `copy.copy`, `copy.deepcopy` and `pickle` reconstruct the instance by restoring its state directly rather than calling `__init__`, so the hook never fires and derived values are carried over from the source object instead of being recomputed. If a derived field must be recomputed on restore, do it in `__setstate__` or rebuild the object through its constructor.
saying these in an interview costs you the question
- Says __post_init__ replaces the generated __init__
- Thinks it runs on every attribute assignment
- Expects it to fire under @dataclass(init=False)
- Believes base and subclass versions both run automatically
- Thinks returning a value from it changes the instance
- Assumes it receives every field as an argument