Dataclasses: less boilerplate
Look at what a simple data-holding class costs:
class Expense:
def __init__(self, amount: float, category: str, note: str = ""):
self.amount = amount
self.category = category
self.note = note
def __repr__(self) -> str:
return (f"Expense(amount={self.amount!r}, category={self.category!r}, "
f"note={self.note!r})")
def __eq__(self, other) -> bool:
if not isinstance(other, Expense):
return NotImplemented
return ((self.amount, self.category, self.note)
== (other.amount, other.category, other.note))
Every field written three times, and the whole thing is mechanical. Worse, it
rots — add a field and forget to update __eq__, and you have a subtle bug with
no error.
The same thing as a dataclass
from dataclasses import dataclass
@dataclass
class Expense:
amount: float
category: str
note: str = ""
That is equivalent, and better — __init__, __repr__ and __eq__ are
generated from the fields, so they cannot drift.
e = Expense(250, "food", "lunch")
print(e)
print(e == Expense(250, "food", "lunch"))
Expense(amount=250, category='food', note='lunch')
True
The type hints are required. A dataclass finds its fields by looking at the
annotations, so a field without one is silently ignored. They are still not
enforced at runtime — Expense("abc", "food") works, exactly as module 5 said.
Defaults work as normal, and as normal must come after fields without them.
Methods still work
A dataclass is an ordinary class with some methods written for you:
@dataclass
class Expense:
amount: float
category: str
note: str = ""
GST_RATE = 0.18 # no annotation, so not a field
def with_gst(self) -> float:
return round(self.amount * (1 + self.GST_RATE), 2)
def describe(self) -> str:
return f"{self.category}: ₹{self.amount:,.2f}"
GST_RATE has no annotation, so it is an ordinary class attribute rather than a
constructor parameter. That is the mechanism, and it is easy to trip over in
both directions.
Validation
__post_init__ runs after the generated __init__:
@dataclass
class Expense:
amount: float
category: str
note: str = ""
def __post_init__(self) -> None:
if self.amount < 0:
raise ValueError(f"Amount cannot be negative, got {self.amount}")
if self.category not in {"food", "transport", "rent", "other"}:
raise ValueError(f"Unknown category: {self.category!r}")
The invariants from the first lesson, with none of the boilerplate.
The mutable default, again
@dataclass
class Report:
expenses: list = []
ValueError: mutable default <class 'list'> for field expenses is not allowed:
use default_factory
Python refuses at class-definition time. This is module 5's mutable default trap, and dataclasses are the one place it is caught for you rather than producing a silent bug.
from dataclasses import dataclass, field
@dataclass
class Report:
expenses: list[Expense] = field(default_factory=list)
default_factory=list calls list() for each new object — a fresh list every
time, which is what basket=None achieved manually.
Frozen dataclasses
@dataclass(frozen=True)
class Point:
x: int
y: int
p = Point(1, 2)
p.x = 5
FrozenInstanceError: cannot assign to field 'x'
Immutable after creation, and frozen dataclasses are hashable, so they work
as dictionary keys and in sets — without writing __hash__.
That makes them an excellent replacement for tuples when the fields deserve names. Module 4's coordinate key:
locations = {Point(19.07, 72.87): "Mumbai"}
point.x beats point[0], and it is still a valid key.
Use frozen=True for value objects — coordinates, money, configuration,
anything that represents a value rather than a thing with a lifecycle.
Useful options
@dataclass(frozen=True, order=True, slots=True)
class Point:
x: int
y: int
order=True generates <, <=, >, >=, comparing fields in order — so
sorting works with no __lt__.
slots=True (Python 3.10+) uses less memory and makes attribute access
marginally faster, at the cost of preventing new attributes being added later.
Reasonable for objects you create in large numbers.
Per-field control:
@dataclass
class User:
name: str
email: str
password_hash: str = field(repr=False)
created_at: datetime = field(default_factory=datetime.now)
repr=False keeps the password hash out of __repr__ — so it never lands in a
log or an error message. That is a small habit with real security value.
Converting to and from dictionaries
from dataclasses import asdict, astuple
print(asdict(e))
{'amount': 250, 'category': 'food', 'note': 'lunch'}
asdict works recursively through nested dataclasses, which makes saving to
JSON straightforward:
json.dump(asdict(report), file, indent=2)
Coming back the other way, the from_dict classmethod from the methods lesson:
@classmethod
def from_dict(cls, data: dict) -> "Expense":
return cls(**data)
**data spreads the dictionary into keyword arguments, from module 5. That
works when the keys match the field names exactly — and raises TypeError on an
unexpected key, which is a reasonable way to catch bad data early.
When not to use one
When the class is mostly behaviour. A PaymentProcessor with one field and
six methods is not a data class, and the decorator adds nothing.
When you need full control over __init__ — complex setup, several
alternative constructors, work beyond validation.
When you need a different __eq__ than field-by-field comparison. Two users
being equal because their ids match, regardless of other fields, needs writing
by hand.
The alternatives
NamedTuple is a lighter option for immutable records:
from typing import NamedTuple
class Point(NamedTuple):
x: int
y: int
Immutable, hashable, and unpackable like a tuple. A frozen dataclass is more
flexible; NamedTuple is better when you want tuple behaviour.
Pydantic is worth knowing by name. It looks like a dataclass and does enforce types at runtime, converting and validating input. That is what you want at the edge of a system — parsing JSON from an API or a request body — and it is what FastAPI is built on. Not in the standard library, and the right tool when untrusted data arrives.
Check your work
Line count. The handwritten version is roughly 15 lines for three fields; the dataclass is 5. More importantly, the handwritten one repeats each field three times, so it rots.
Forgetting __eq__ when adding a field means two objects differing only in
the new field compare equal. A subtle bug with no error — and the dataclass
version cannot have it, because __eq__ is generated from the fields.
Class attributes without annotations are not fields.
GST_RATE = 0.18 # not a constructor parameter
amount: float # a field
Easy to trip over in both directions: a field without an annotation is silently ignored.
The mutable default is caught at definition time.
ValueError: mutable default <class 'list'> for field expenses is not allowed:
use default_factory
This is the one place Python catches module 5's trap for you rather than
letting it become a silent bug. field(default_factory=list) calls list() per
object.
Frozen dataclasses are hashable, so they work as dictionary keys and in
sets without writing __hash__. That makes them an excellent replacement for
tuples when the fields deserve names — point.x beats point[0].
order=True generates the comparison methods, so sorting works with no
__lt__.
field(repr=False) keeps a password hash out of __repr__, so it never
lands in a log or an error message. A small habit with real security value.
The round trip loses the same things module 7 warned about: tuples become lists, and non-string keys become strings.
Practice
- Write
Expenseby hand with__init__,__repr__and__eq__. Count the lines. Rewrite it as a dataclass and count again. - Add a field to the handwritten version and deliberately forget
__eq__. Watch two different objects compare equal. - Add methods and a class-level constant to a dataclass. Confirm the constant is not a constructor parameter.
- Add
__post_init__validation and test it rejects a negative amount. - Try
expenses: list = []and read the error. Fix it withdefault_factory. - Make a frozen dataclass and try to modify it. Then use it as a dictionary key.
- Add
order=Trueand sort a list of them without writing__lt__. - Use
field(repr=False)on a sensitive field and confirm it stays out of the output. - Round-trip a dataclass through
asdict,json.dumps,json.loadsandcls(**data). Check which types survive, using module 7's warnings.
Next: the most useful lesson in this module — when not to write a class at all.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship