Python

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.

Intermediate

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 value

Recall 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 key

frozen=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?