Dataclasses — less boilerplate for classes that mostly hold data
A class that mostly just holds a handful of typed fields used to require writing __init__, __repr__, and __eq__ by hand, every time — @dataclass generates all three from the type-annotated fields alone, and understanding what it generates is what makes it trustworthy to use.
3 min read
What writing this class by hand actually looked like
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point(x={self.x!r}, y={self.y!r})"
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return (self.x, self.y) == (other.x, other.y)A class whose entire job is holding a few named values still needs __init__ to accept and store them, __repr__ for a readable representation when debugging, and __eq__ for two instances with equal fields to actually compare equal (recall from the dunder-methods lesson that without __eq__, == falls back to identity comparison, so two separately-constructed Point(1, 2) instances would compare unequal by default). Writing all three by hand for every small data-holding class is real, repetitive boilerplate — the exact kind of pattern worth automating.
@dataclass: the same class, generated from type-annotated fields
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1) # Point(x=1, y=2) — a real __repr__, generated automatically
print(p1 == p2) # True — a real __eq__, comparing every field@dataclass reads the class's type-annotated attributes (x: int, y: int) and generates __init__, __repr__, and __eq__ from them automatically — the annotations aren't just documentation here, they're the actual specification the decorator uses to know which fields exist and belong in the generated methods. This is a real, deliberate trade: type annotations become load-bearing, not just informative, in exchange for eliminating the boilerplate entirely.
Default values, and the mutable-default trap dataclasses actually protect against
from dataclasses import dataclass, field
@dataclass
class Cart:
items: list = field(default_factory=list) # NOT items: list = [] — see below
discount: float = 0.0 # a plain default is fine for IMMUTABLE values
# items: list = [] would raise a ValueError at class-definition time —
# dataclasses actively REJECT a mutable literal as a default valueRecall the classic Python gotcha: a mutable default argument (def f(items=[])) is created once, at function-definition time, and shared across every call that doesn't pass its own value — dataclasses don't just inherit this footgun, they actively detect and reject it, raising a ValueError if a field's default is a mutable literal like [] or {}. field(default_factory=list) is the correct fix: default_factory is a callable invoked fresh for each new instance, so every Cart() gets its own genuinely separate list, not a shared one.
frozen=True: making a dataclass genuinely immutable, and hashable
@dataclass(frozen=True)
class Point:
x: int
y: int
p = Point(1, 2)
p.x = 5 # FrozenInstanceError — assignment is blocked after construction
point_set = {Point(1, 2), Point(3, 4)} # frozen dataclasses are hashable — usable in a set/dict keyfrozen=True makes every field genuinely read-only after construction — attempting to reassign one raises FrozenInstanceError, a real, enforced restriction, not just a naming convention. It also makes instances hashable by default (based on the same fields __eq__ compares), which a plain mutable dataclass isn't — usable as a dictionary key or set member, exactly the pattern a small, immutable value object (a coordinate, a currency amount) usually wants.
@dataclass is still a real class — everything else about classes still applies
@dataclass
class Circle:
radius: float
def area(self) -> float: # ordinary methods work exactly as normal
return 3.14159 * self.radius ** 2@dataclass only generates the specific methods described above — it doesn't replace or restrict anything else about how classes work. Custom methods, inheritance, class methods, properties — everything from earlier lessons in this domain applies to a dataclass exactly as it would to any other class, since @dataclass is simply adding generated methods to an otherwise ordinary class definition, not creating a fundamentally different kind of object.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. What does @dataclass actually generate from a class's type-annotated fields?
2. Why do dataclasses reject a mutable literal like `items: list = []` as a default value?
3. What does `frozen=True` actually change about a dataclass?