2.1.16 · D3OOP Fundamentals

Worked examples — Dataclasses — `@dataclass` decorator, `__post_init__`

3,013 words14 min readBack to topic

Before we start, one word we'll lean on: a field is a type-annotated class attribute that @dataclass turns into a constructor argument. A dunder is a "double-underscore" method like __init__ that Python calls automatically. If either feels shaky, revisit Dunder Methods __init__ __repr__ __eq__ and Type Annotations in Python.


The scenario matrix

Think of every dataclass you could ever write as a point on a grid. There are two core axes of variation, and everything else is a stress-test around their edges. The figure below draws the grid: the horizontal axis is mutable vs immutable data, the vertical axis is supplied vs derived state. The four inner cells (A, B, C, F) are the "pure" combinations; the outer band (D, E, G, H, I) holds the edge cases — validation failure, hashing, the degenerate empty class, a real-world word problem, and an exam trap — that live around the grid.

Figure — Dataclasses — `@dataclass` decorator, `__post_init__`
Figure: the two axes (blue = mutable↔immutable, yellow = supplied↔derived) define four inner cells; the red band around them collects the edge-case scenarios that don't sit at a single grid point.

# Scenario cell What varies Example
A Plain immutable-type defaults (inner grid) int/str default value Ex 1
B Mutable default (inner grid, the trap) list/dict field Ex 2
C Derived field via __post_init__ (inner grid) field(init=False) Ex 3
F Frozen and derived (inner grid, the conflict) object.__setattr__ Ex 6
D Edge: validation that fails bad input raises Ex 4
E Edge: frozen + hashable frozen=True, dict key Ex 5
G Edge: degenerate empty / no-field class zero fields Ex 7
H Edge: real-world word problem order line total Ex 8
I Edge: exam twist, default-ordering error required-after-default Ex 9

The four inner cells are the pure crossings of the two axes; the five edge scenarios stress-test the boundaries (a failure, a limiting hashing requirement, a zero-field degenerate input, a real application, and an exam-style twist). Together they leave no cell — inner or edge — unvisited. Let's walk them.


Ex 1 — Cell A: plain defaults, equality for free


Ex 2 — Cell B: the mutable default trap


Ex 3 — Cell C: a derived field


Ex 4 — Cell D: validation that fails


Ex 5 — Cell E: frozen and hashable


Ex 6 — Cell F: frozen AND derived (the conflict)


Ex 7 — Cell G: the degenerate empty class


Ex 8 — Cell H: real-world word problem


Ex 9 — Cell I: exam twist, ordering error


Recall One-line recall for every cell

Plain defaults give value-equality ::: __eq__ compares the field-tuple. Mutable default = [] ::: class defines fine but shares one list across instances; use field(default_factory=list). Derived field ::: field(init=False) + assign in __post_init__. Frozen + derived ::: object.__setattr__(self, name, value); a plain self.x=... raises FrozenInstanceError. Required after default field ::: TypeError at class creation; reorder fields.

See also: NamedTuple vs Dataclass vs TypedDict for when a different data holder fits better, and OOP Fundamentals for the surrounding class concepts.