Intuition What this page is
> The parent note taught you the machinery . Here we run that machinery through every kind of input it can meet — every default type, every frozen/mutable combination, the degenerate empty class, the validation failure, a real-world word problem, and an exam-style trap. If a scenario exists, it has a worked example below.
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 .
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: 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.
from dataclasses import dataclass
@dataclass
class Tag :
name: str
priority: int = 1
a = Tag( "bug" )
b = Tag( "bug" , 1 )
c = Tag( "bug" , 2 )
Predict a == b and a == c.
Forecast: guess each before reading on. (Hint: what does the generated __eq__ compare?)
Step 1 — What is a? Tag("bug") fills name="bug" and takes the default priority=1.
Why this step? The default = 1 is a value stored on the class; the generated __init__ copies it in when you omit the argument.
Step 2 — Compare a == b. Generated __eq__ compares the tuple of fields : ("bug", 1) == ("bug", 1).
Why this step? Tuple comparison is element-by-element, so equal fields ⇒ equal objects. This is why value-equality is "free."
Step 3 — Compare a == c. ("bug", 1) == ("bug", 2) → the second element differs → False.
Why this step? One differing field is enough to make the whole tuple unequal.
Verify: a == b is True, a == c is False. Sanity check: without @dataclass, a plain class compares by identity (memory address), so a == b would be False — the decorator is what changed the behaviour.
You want a shopping cart whose items starts empty. A beginner writes:
@dataclass
class BadCart :
items: list = [] # what happens?
Forecast: does this (a) work fine, (b) silently share one list across all instances, or (c) crash immediately?
Step 1 — Recall the danger. In ordinary functions, a mutable default like def f(x=[]) is created once and reused, so appends leak between calls (see Default Arguments and Mutable Default Trap ).
Why this step? The same danger applies here: a single list object could end up shared by every instance.
Step 2 — What Python actually does. The dataclass machinery treats the default exactly like a normal function default : the one list literal [] is created once at class-definition time , and that same object becomes the default for items on every instance built without an argument. So BadCart is defined successfully — but every default-constructed cart shares one list. The correct answer to the forecast is therefore (b) silently share one list across all instances .
Why this step? This is the subtle, dangerous case: nothing crashes, so the bug only surfaces later when one cart's append mysteriously shows up in another.
Step 3 — The fix. Use a factory that makes a fresh object per instance:
from dataclasses import dataclass, field
@dataclass
class GoodCart :
items: list = field( default_factory = list )
Why this step? default_factory is a callable invoked once per __init__ , so each cart gets its own list.
Verify: with the buggy version, b1 = BadCart(); b2 = BadCart(); b1.items.append("apple") leaves b2.items == ["apple"] too, and b1.items is b2.items is True — proof of the shared object. With the fix, c1 = GoodCart(); c2 = GoodCart(); c1.items.append("apple") gives c1.items == ["apple"], c2.items == [], and c1.items is c2.items is False.
A rectangle should compute its own area — the caller must not pass it.
from dataclasses import dataclass, field
@dataclass
class Rectangle :
width: float
height: float
area: float = field( init = False )
def __post_init__ (self):
self .area = self .width * self .height
r = Rectangle( 3 , 4 )
Forecast: what is r.area, and is Rectangle(3, 4, 12) legal?
Step 1 — Read field(init=False). This removes area from the constructor's parameter list.
Why this step? area is computed , not supplied — accepting it as an argument would let callers pass a wrong value.
Step 2 — Trace the order. The generated __init__ first assigns self.width=3, self.height=4, then calls self.__post_init__() as its last line. The figure below draws this left-to-right timeline: the two blue boxes are the field assignments, the green box is the __post_init__ call, and the red text shows the derived area being computed once both fields exist.
Why this step? Because width and height already exist by the time __post_init__ runs, it can read them to compute area.
Figure: the auto-generated __init__ assigns each field (blue) in order, then calls __post_init__ (green) as its very last statement, which computes area = width * height = 3 * 4 = 12 (red).
Step 3 — Compute. area = 3 * 4 = 12.
Why this step? This is the whole reason __post_init__ exists — a place to fill derived state after assignment.
Verify: r.area == 12. And Rectangle(3, 4, 12) raises TypeError (too many arguments) because area is not a constructor parameter — confirming init=False did its job.
Temperature can't drop below absolute zero. Convert to Fahrenheit, but reject impossible input.
from dataclasses import dataclass, field
@dataclass
class Celsius :
temp: float
fahrenheit: float = field( init = False )
def __post_init__ (self):
if self .temp < - 273.15 :
raise ValueError ( "below absolute zero" )
self .fahrenheit = self .temp * 9 / 5 + 32
Predict Celsius(100).fahrenheit and what Celsius(-300) does.
Forecast: one returns a number; one raises. Guess which value / which error.
Step 1 — See the conversion as a picture, not a formula. Fahrenheit is a straight-line "re-labelling" of Celsius: multiply by a slope of 9/5 (each Celsius degree is 1.8 Fahrenheit degrees), then shift up by 32 (so that 0 ∘ C lands on 32 ∘ F). The figure below plots that line; reading off C = 100 gives F = 212 .
Why a straight line? Because the two scales are linked by "stretch then slide" — the only operations a straight line performs — so a line is exactly the right tool to picture the conversion.
Figure: the green line F = 5 9 C + 32 . Yellow marks the shift ( 0 , 32 ) ; red marks the read-off ( 100 , 212 ) ; the dashed guides trace C = 100 → F = 212 .
Step 2 — Valid path. Celsius(100): temp is assigned, __post_init__ runs, the -273.15 check passes, and we read the value off the line: F = 100 × 1.8 + 32 = 180 + 32 = 212 .
Why this step? Validation and derivation both live in __post_init__ because that's the first moment self.temp exists.
Step 3 — Invalid path. Celsius(-300): the check -300 < -273.15 is True, so raise ValueError.
Why this step? Failing loudly during construction stops a physically-impossible object from ever existing.
Verify: Celsius(100).fahrenheit == 212.0. Celsius(-300) raises ValueError. Boundary check: Celsius(-273.15) is valid (the test is strict <), giving − 273.15 × 1.8 + 32 = − 459.67 .
@dataclass ( frozen = True )
class Money :
amount: int
currency: str
m = Money( 10 , "USD" )
prices = {m: "ten dollars" }
Forecast: does using m as a dict key work? Would it work if the class weren't frozen?
Step 1 — Why mutable dataclasses aren't hashable. A dict key's hash must never change. Mutable objects could change, so @dataclass sets __hash__ = None for them, making them un-hashable on purpose (see Immutability and Hashing ).
Why this step? Prevents a key silently "moving" inside the dict and getting lost.
Step 2 — What frozen=True changes. It blocks every self.x = ... after construction, so fields are permanent. Now a stable hash is safe, and Python generates __hash__ for you.
Why this step? Immutability is exactly the condition that makes hashing sound.
Step 3 — Use it. prices[m] looks up by hash+equality, both derived from (amount, currency).
Why this step? Two Money(10,"USD") values hash and compare equal, so they'd collapse to the same key.
Verify: prices[Money(10, "USD")] == "ten dollars" — a freshly built equal Money retrieves the value, confirming hash+eq are value-based. Also hash(Money(10,"USD")) == hash(Money(10,"USD")) is True.
A frozen point should also store its distance from origin. But frozen=True blocks self.dist = ...!
import math
from dataclasses import dataclass, field
@dataclass ( frozen = True )
class Vec :
x: float
y: float
dist: float = field( init = False )
def __post_init__ (self):
object . __setattr__ ( self , "dist" , math.hypot( self .x, self .y))
Forecast: what is Vec(3, 4).dist, and why can't we just write self.dist = ...?
Step 1 — See the conflict. frozen=True overrides __setattr__ to raise FrozenInstanceError on any attribute write — including inside __post_init__. So a naive self.dist = ... would raise FrozenInstanceError at construction time.
Why this step? The freeze can't tell "legit derived write" from "illegal mutation"; it blocks all, and the exception it raises is specifically dataclasses.FrozenInstanceError.
Step 2 — Bypass with the base method. object.__setattr__(self, "dist", value) calls the original setter, sidestepping the frozen override — used exactly once, at construction.
Why this step? This is the sanctioned escape hatch; it keeps the object effectively immutable afterward.
Step 3 — Compute. math.hypot(3, 4) = 3 2 + 4 2 = 25 = 5 .
Why this step? hypot answers "length of the vector" without you squaring/adding/rooting by hand.
Verify: Vec(3, 4).dist == 5.0. Also Vec(0, 0).dist == 0.0 (degenerate zero vector) — confirming the formula survives the all-zeros edge case. And a version using self.dist = ... instead raises FrozenInstanceError.
What does @dataclass do when there are no fields at all ?
@dataclass
class Nothing :
pass
@dataclass
class Sentinel :
pass
Forecast: does Nothing() == Nothing()? Does Nothing() == Sentinel()?
Step 1 — Empty field list. With zero annotations, the generated __init__ takes no arguments and the field-tuple is ().
Why this step? The machinery still runs; it just iterates over an empty set of fields.
Step 2 — Equality of empties. __eq__ first checks other.__class__ is self.__class__, then compares () == ().
Why this step? The empty-tuple comparison is trivially True, but only after the class-identity guard passes.
Step 3 — Cross-class. Nothing() == Sentinel(): the class-identity guard fails, so __eq__ returns NotImplemented, and Python ultimately yields False.
Why this step? Two different types are never "equal by value," even if both hold no data.
Verify: Nothing() == Nothing() is True; Nothing() == Sentinel() is False.
An order line has a unit price and a quantity. Compute its total, apply a 10% discount if quantity ≥ 10, and reject non-positive quantities.
from dataclasses import dataclass, field
@dataclass
class OrderLine :
unit_price: float
quantity: int
total: float = field( init = False )
def __post_init__ (self):
if self .quantity <= 0 :
raise ValueError ( "quantity must be positive" )
raw = self .unit_price * self .quantity
self .total = raw * 0.9 if self .quantity >= 10 else raw
Forecast: compute total for (2.50, 4) and for (2.50, 10).
Step 1 — Validate. Both quantities are positive, so no error.
Why this step? Guard clause first — never compute a total for nonsense input.
Step 2 — Small order (2.50, 4). raw = 2.50 * 4 = 10.0; quantity 4 < 10, so no discount → total = 10.0.
Why this step? The branch chooses the raw value when the discount condition is false.
Step 3 — Bulk order (2.50, 10). raw = 2.50 * 10 = 25.0; quantity 10 ≥ 10, so total = 25.0 * 0.9 = 22.5.
Why this step? The boundary quantity == 10 uses >=, so it does qualify — always test the boundary.
Verify: OrderLine(2.50, 4).total == 10.0 and OrderLine(2.50, 10).total == 22.5. Units: dollars × count = dollars; discount is dimensionless — consistent.
This looks harmless but won't even define. Why?
from dataclasses import dataclass
@dataclass
class Broken :
name: str = "anon"
age: int # no default!
Forecast: runs fine, or crashes at class-creation? What error?
Step 1 — Map fields to constructor parameters. The generated __init__ becomes def __init__(self, name="anon", age): ... — name has a default, age does not.
Why this step? A field with a default becomes a parameter with a default; a field without one becomes a required parameter, in source order.
Step 2 — Spot the illegal signature. In Python you can never place a required parameter after one with a default — def f(a=1, b) is a SyntaxError. The dataclass machinery detects the same situation and raises TypeError at class-creation time with the message "non-default argument 'age' follows default argument."
Why this step? If a required argument sat after a defaulted one, the caller couldn't tell which positional value they were supplying — so the language (and the decorator) forbids it up front.
Step 3 — The fix. Put fields without defaults first, so every defaulted field trails the required ones:
@dataclass
class Fixed :
age: int
name: str = "anon"
Why this step? Now the generated signature is def __init__(self, age, name="anon"), a legal ordering, so Fixed(30) works and fills name="anon".
Verify: defining Broken raises TypeError. After reordering, Fixed(30).age == 30 and Fixed(30).name == "anon".
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.
Mnemonic The scenario checklist
"MDF-FE" — is it M utable? D erived? does it F ail validation? is it F rozen? and is field-order E rror-free? Answer those five and you've covered the matrix.
See also: NamedTuple vs Dataclass vs TypedDict for when a different data holder fits better, and OOP Fundamentals for the surrounding class concepts.