Exercises — Dataclasses — `@dataclass` decorator, `__post_init__`
Level 1 — Recognition
Goal: read the code, predict what Python prints. No writing yet.
Exercise 1.1 — What does __eq__ compare?
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
a = Point(1, 2)
b = Point(1, 2)
c = Point(2, 1)
print(a == b, a == c, a is b)What are the three printed values?
Recall Solution 1.1
Answer: True False False
- WHAT
@dataclassgenerated an__eq__that compares the tuple of fields:(a.x, a.y) == (b.x, b.y). - WHY
a == bisTrue: the field tuples are(1, 2) == (1, 2)→ equal by value. - WHY
a == cisFalse:(1, 2) == (2, 1)→ not equal. - WHY
a is bisFalse:isasks "same object in memory?" — these are two separate constructions, so identity differs even though values match.==andisanswer different questions. See Dunder Methods __init__ __repr__ __eq__.
Exercise 1.2 — Is this a field?
from dataclasses import dataclass
@dataclass
class Config:
name: str
version = "1.0" # no annotation!
print(Config.__annotations__)
Config("app")Which names are fields, and does Config("app") succeed?
Recall Solution 1.2
Answer: __annotations__ is {'name': <class 'str'>} — only name is a field. Config("app") succeeds.
- WHY: The decorator discovers fields by reading ==
__annotations__==.version = "1.0"has no type annotation, so it is an ordinary class attribute, invisible to@dataclass. - Therefore
__init__takes onlyname.Config.versionstill exists as a plain class attribute ("1.0") but you cannot pass it to the constructor. - See Type Annotations in Python for why the annotation, not the assignment, is the trigger.
Level 2 — Application
Goal: write the dataclass yourself from a spec.
Exercise 2.1 — A Book with a default
Write a dataclass Book with fields title: str, author: str, and in_stock: int defaulting to 0. Then predict what repr(Book("Dune", "Herbert")) produces.
Recall Solution 2.1
from dataclasses import dataclass
@dataclass
class Book:
title: str
author: str
in_stock: int = 0- WHY the default goes last: in
__init__, parameters with defaults must come after those without. Field order = source order, so a defaulted field placed before a non-defaulted one would raiseTypeErrorat class creation. reproutput:
Book(title='Dune', author='Herbert', in_stock=0)
The generated __repr__ lists every field as name=value using !r (the repr of each value), which is why strings appear in quotes.
Exercise 2.2 — Cart with a safe list default
Write a dataclass Cart holding items (a list) that starts empty and is not shared across instances. Then run:
c1 = Cart()
c1.items.append("apple")
c2 = Cart()
print(c1.items, c2.items)What prints?
Recall Solution 2.2
from dataclasses import dataclass, field
@dataclass
class Cart:
items: list = field(default_factory=list)Answer: ['apple'] []
- WHY
field(default_factory=list):default_factoryis a callable invoked once per__init__, so every instance gets a fresh list.c1andc2therefore hold different lists — appending to one does not touch the other. - See Default Arguments and Mutable Default Trap.
Level 3 — Analysis
Goal: explain why something breaks or behaves oddly.
Exercise 3.1 — Order of assignment in __post_init__
from dataclasses import dataclass, field
@dataclass
class Circle:
radius: float
area: float = field(init=False)
def __post_init__(self):
self.area = 3.14159 * self.radius ** 2
print(Circle(2).area)(a) What prints? (b) Explain why self.radius is already available inside __post_init__.
Recall Solution 3.1
(a) 12.56636 (since ).
(b) WHY radius is ready: the generated __init__ runs as a simple two-step timeline. Read it top-to-bottom like a checklist:
Circle(2) called
│
▼
[ phase 1 ] assign every ordinary field
self.radius = 2 <- caller's argument copied in
│
▼
[ phase 2 ] run the hook (last statement of __init__)
self.__post_init__()
└─ self.area = 3.14159 * self.radius ** 2 <- reads radius, now safe
│
▼
object handed back to caller (radius=2, area=12.56636)By the time __post_init__ executes, all ordinary fields are already assigned. area is field(init=False), so it is not in the constructor's parameter list — the caller writes Circle(2), and area is filled by our hook. Derived logic must live in phase 2 precisely because it reads phase-1 values.
Exercise 3.2 — Why can't a plain (non-frozen) dataclass be a dict key?
from dataclasses import dataclass
@dataclass
class Tag:
name: str
d = {Tag("x"): 1} # what happens?Explain the error and its deeper reason.
Recall Solution 3.2
Answer: raises TypeError: unhashable type: 'Tag'.
- WHY: When a dataclass defines
__eq__(which it does by default) but stays mutable, Python sets__hash__ = None. An object is unhashable when its__hash__isNone. - Deeper reason: a dict key's hash must never change while it sits in the dict. But
Tagis mutable — you could dot.name = "y"after inserting it, silently corrupting the dict's bucket placement. Python forbids the whole situation up front by making mutable-with-__eq__dataclasses unhashable. - Fix:
@dataclass(frozen=True)→ attributes can't change → hash is stable → Python generates__hash__. See Immutability and Hashing.
Level 4 — Synthesis
Goal: combine several features into one working design.
Exercise 4.1 — Frozen + derived field
Build an immutable Vector2D with fields x: float, y: float, and a derived magnitude: float computed at construction (). It must be usable as a dict key. Then predict:
v = Vector2D(3, 4)
print(v.magnitude)
print({v: "here"}[Vector2D(3, 4)])Before reading the solution, study the figure below — it shows why the derived magnitude field is exactly the length of the arrow.

Recall Solution 4.1
from dataclasses import dataclass, field
import math
@dataclass(frozen=True)
class Vector2D:
x: float
y: float
magnitude: float = field(init=False)
def __post_init__(self):
object.__setattr__(self, "magnitude", math.hypot(self.x, self.y))Output: 5.0 then here.
- WHY
object.__setattr__:frozen=Trueblocks every normalself.attr = ..., including inside__post_init__. To set the one derived field we bypass the freeze withobject.__setattr__(self, "magnitude", value). - WHY
5.0: as the figure shows, — the plum arrow (hypotenuse) of the 3-4-5 right triangle. - WHY the dict lookup returns
"here": frozen dataclasses are hashable and compare by value.Vector2D(3, 4)built twice has equal field tuples(3, 4, 5.0), so it hashes the same and==matches → same key.
Exercise 4.2 — Validation that rejects bad input
Extend the idea: write a frozen Percentage with one field value: float that must lie in . Construction outside that range raises ValueError. What does Percentage(150) do, and what does Percentage(42).value return?
Recall Solution 4.2
from dataclasses import dataclass
@dataclass(frozen=True)
class Percentage:
value: float
def __post_init__(self):
if not (0 <= self.value <= 100):
raise ValueError("percentage must be between 0 and 100")Percentage(150)→ raisesValueError. WHY:__post_init__runs aftervalueis assigned; the guard0 <= 150 <= 100isFalse, so it raises before the object is handed back.Percentage(42).value→42. It passes the guard, so construction succeeds and the field is readable (reading is always allowed; only writing is frozen).
Level 5 — Mastery
Goal: reproduce, by hand, what the decorator generates.
Exercise 5.1 — Hand-write the equivalent class
Given:
@dataclass
class Employee:
name: str
salary: float = 50000.0Write, without @dataclass, a plain class that produces the same __init__, __repr__, and __eq__ behaviour. Then confirm Employee("Ada") == Employee("Ada") is True.
Recall Solution 5.1
class Employee:
def __init__(self, name: str, salary: float = 50000.0):
self.name = name
self.salary = salary
def __repr__(self):
return f"Employee(name={self.name!r}, salary={self.salary!r})"
def __eq__(self, other):
if other.__class__ is not self.__class__:
return NotImplemented
return (self.name, self.salary) == (other.name, other.salary)- WHY parameter order: exactly the source field order —
namefirst (no default),salarysecond (with default50000.0). Defaults must come last. - WHY the
__repr__shape: dataclass emitsClassName(field=value, ...)using!rfor each value. - WHY
__eq__compares tuples: tuple comparison gives field-by-field equality for free and returnsNotImplementedfor mismatched types (letting Python try the other operand's__eq__). - Check:
Employee("Ada") == Employee("Ada")compares("Ada", 50000.0) == ("Ada", 50000.0)→True. - Compare this generated machinery against NamedTuple vs Dataclass vs TypedDict to see which parts each alternative gives you.
Exercise 5.2 — Trace the full pipeline (FIDE-P)
For this class, list in order every step the decorator performs at class-creation and every step __init__ performs at instance-creation, using the FIDE-P mnemonic:
@dataclass
class Order:
qty: int
price: float
total: float = field(init=False)
def __post_init__(self):
self.total = self.qty * self.priceWhat is Order(3, 20.0).total?
Recall Solution 5.2
Answer: Order(3, 20.0).total == 60.0.
Class-creation time (decorator runs once):
- F — Fields collected: reads
__annotations__→qty, price, totalin order. - I — Init generated: builds
__init__(self, qty, price)— notetotalis excluded becausefield(init=False). - D — Defaults applied: none here for
qty/price;totalhas no constructor default (it's set later). - E — Eq/repr made:
__repr__and__eq__generated over the tuple(qty, price, total).
Instance-creation time (Order(3, 20.0)):
self.qty = 3,self.price = 20.0(phase-1 assignment).- P — Post_init runs last:
self.total = 3 * 20.0 = 60.0.
WHY the ordering guarantees correctness: total reads self.qty and self.price, which only exist after phase-1 assignment. Because __post_init__ is invoked as the last statement of the generated __init__, those reads are always safe — the derived value can never be computed from a field that hasn't been set yet. This is the same two-phase timeline you saw in Solution 3.1, now with the derivation depending on two inputs instead of one.
Closing remark: notice you did not pass total to the constructor — Order(3, 20.0) has only two arguments. That is field(init=False) at work, and it is exactly the trap the note below warns about.
Recall Feynman recap
A @dataclass is a robot that reads your list of annotated blanks and writes the form-filling code for you: build (__init__), read out loud (__repr__), compare (__eq__), and — if you say frozen=True — freeze + fingerprint (__hash__). __post_init__ is the "now double-check and compute extras" step that runs last, once every blank is filled.