Less Boilerplate, More Data: A Practical Deep Dive into Python's dataclasses
Learn how Python's dataclasses eliminate boilerplate __init__, __repr__, and __eq__ methods — plus field(), default_factory, __post_init__, frozen and slotted instances, ordering, and the mutable-default trap.
Almost every Python project accumulates small classes whose only job is to hold data: a Point, a Config, an InventoryItem. Writing them by hand means typing the same __init__, __repr__, and __eq__ over and over, and every one of those hand-written methods is a place for a typo to hide. The dataclasses module, part of the standard library since Python 3.7, generates all of that boilerplate for you from nothing more than a class body with type annotations. This article walks through how it works, the options that matter in real code, and the pitfalls that trip people up.
The problem dataclasses solve
Here is the class most people write on autopilot:
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)
Fifteen lines, and two-thirds of them are mechanical. The dataclass version says the same thing:
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
p = Point(1, 2)
print(p) # Point(x=1, y=2)
print(Point(1, 2) == Point(1, 2)) # True
The @dataclass decorator inspects the class annotations and writes __init__, __repr__, and __eq__ for you. The annotations are required — that is how the decorator discovers which attributes are fields. A bare x = 0 without an annotation is treated as a plain class attribute and ignored by the machinery.
Defaults and the mutable-default trap
Fields can have defaults, exactly like function arguments — and, exactly like function arguments, fields with defaults must come after fields without them. The subtlety is mutable defaults. You cannot write tags: list = [], because that single list would be shared across every instance, which is the classic Python footgun. Dataclasses refuse to let you do it:
from dataclasses import dataclass
@dataclass
class Bad:
items: list = [] # raises at class-definition time
# ValueError: mutable default <class 'list'> for field items
# is not allowed: use default_factory
The fix is field(default_factory=...), which calls the factory once per instance to produce a fresh object:
from dataclasses import dataclass, field
@dataclass
class InventoryItem:
name: str
unit_price: float
quantity: int = 0
tags: list[str] = field(default_factory=list)
def total_cost(self) -> float:
return self.unit_price * self.quantity
a = InventoryItem("widget", 3.5, 10)
a.tags.append("sale")
b = InventoryItem("gadget", 1.0)
print(a.total_cost()) # 35.0
print(a.tags, b.tags) # ['sale'] []
Note that dataclasses are perfectly ordinary classes — you can add methods like total_cost alongside the generated ones, and they behave exactly as you would expect.
Fine-tuning fields with field()
The field() helper does more than supply factories. It controls whether a field appears in the generated methods. Common options are repr=False to keep a value out of the printed representation, compare=False to exclude it from equality and ordering, and init=False to leave it out of the constructor.
from dataclasses import dataclass, field
@dataclass
class User:
username: str
password: str = field(repr=False) # never printed
id: int = field(default=0, compare=False)
u = User("alice", "s3cret", 42)
print(u) # User(username='alice', id=42) -- password hidden
print(User("alice", "x", 1) == User("alice", "x", 2)) # True
Because id is excluded from comparison, two users with the same username and password are considered equal even when their IDs differ — useful when an ID is an incidental database detail rather than part of the object's identity.
Computed fields with __post_init__
Sometimes a field is derived from the others and should not be passed to the constructor. Mark it init=False and compute it in __post_init__, a hook that the generated __init__ calls after assigning the regular fields:
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)
print(r.area) # 12
__post_init__ is also the right place for validation — raise ValueError there if, say, a width is negative.
Immutability with frozen instances
Passing frozen=True makes instances read-only: any attempt to assign to a field after construction raises FrozenInstanceError. Frozen dataclasses are also hashable by default, so they work as dictionary keys and set members — ideal for value objects like configuration or coordinates.
from dataclasses import dataclass, FrozenInstanceError
@dataclass(frozen=True)
class Config:
host: str
port: int
c = Config("localhost", 8080)
print(hash(c) is not None) # True -- usable as a dict key
try:
c.port = 9090
except FrozenInstanceError:
print("cannot mutate a frozen instance")
One caveat: frozen=True freezes the reference, not what it points to. A frozen dataclass holding a list can still have that list mutated in place. If you need deep immutability, store tuples instead of lists.
Ordering
Add order=True and the decorator generates __lt__, __le__, __gt__, and __ge__. Comparisons work field by field, in definition order, as if the fields were a tuple — which makes dataclasses sortable out of the box:
from dataclasses import dataclass
@dataclass(order=True)
class Version:
major: int
minor: int
patch: int
versions = [Version(1, 2, 0), Version(1, 0, 5), Version(1, 2, 3)]
print(sorted(versions))
# [Version(major=1, minor=0, patch=5),
# Version(major=1, minor=2, patch=0),
# Version(major=1, minor=2, patch=3)]
If you want to sort by a value that is not the natural field order — say, a priority computed in __post_init__ — put that value first and mark the other fields compare=False.
Helper functions
The module ships a handful of functions that operate on any dataclass instance. asdict() and astuple() recursively convert instances to dictionaries and tuples (handy for serialization), replace() returns a copy with some fields changed (the standard way to "modify" a frozen instance), and fields() introspects the field definitions.
from dataclasses import dataclass, asdict, astuple, replace, fields
@dataclass
class Point:
x: int
y: int
p = Point(1, 2)
print(asdict(p)) # {'x': 1, 'y': 2}
print(astuple(p)) # (1, 2)
print(replace(p, y=99)) # Point(x=1, y=99) -- p is untouched
print([f.name for f in fields(Point)]) # ['x', 'y']
Slots and keyword-only fields
Two newer options are worth knowing. On Python 3.10+, slots=True generates a class with __slots__, which lowers per-instance memory and speeds up attribute access by dropping the per-instance __dict__. Also in 3.10, kw_only=True makes every field keyword-only in the constructor, which sidesteps the "defaults must come last" restriction and makes call sites self-documenting.
from dataclasses import dataclass
@dataclass(slots=True, kw_only=True)
class Account:
owner: str
balance: float = 0.0
a = Account(owner="bob", balance=100.0)
print(a) # Account(owner='bob', balance=100.0)
print(hasattr(a, "__dict__")) # False -- no per-instance dict
One trade-off with slots=True: a slotted class cannot also have class-level default values assigned the old way, and it interacts with inheritance more strictly, so reach for it when memory or attribute-access speed actually matters.
When not to use a dataclass
Dataclasses are the right tool when a class is fundamentally a bundle of typed fields. They are less appropriate when you need heavy input validation and coercion (a library like pydantic or attrs gives you more), when the object is defined mostly by behavior rather than data, or when you want a lightweight immutable record with no methods at all (a typing.NamedTuple is leaner). And if you are only passing a fixed set of named values around, a plain NamedTuple is often enough.
Wrap-up and next steps
Dataclasses turn a paragraph of mechanical boilerplate into a few annotated lines, and the generated __init__, __repr__, and __eq__ are correct by construction. Start with the plain @dataclass decorator, reach for field(default_factory=...) the moment a default is mutable, add frozen=True for value objects you want hashable and safe, and use __post_init__ for derived fields and validation. From here, explore dataclasses.replace() as a functional-update pattern, compare the ergonomics against attrs and pydantic for validation-heavy code, and try slots=True on a hot class to see the memory difference for yourself.