Most classes exist to hold a few fields. @dataclass writes the constructor, the repr and
the equality for you, and type hints let a checker catch mistakes before the program runs. Together they
remove most of the boilerplate from the previous two posts.
The problem
Here is the class from Class, written out in full:
class Account:
def __init__(self, number, balance):
self.number = number
self.balance = balance
def __repr__(self):
return f"Account({self.number!r}, {self.balance!r})"
def __eq__(self, other):
if not isinstance(other, Account):
return NotImplemented
return (self.number, self.balance) == (other.number, other.balance)
print(Account("1001", 1250) == Account("1001", 1250)) # Output: True
Thirteen lines, of which two carry information — the field names. Every one of the others is mechanical, and every one is a place to make a typo.
@dataclass
from dataclasses import dataclass
@dataclass
class Account:
number: str
balance: float = 0.0
a = Account("1001", 1250.0)
print(a) # Output: Account(number='1001', balance=1250.0)
print(a.balance) # Output: 1250.0
print(a == Account("1001", 1250.0)) # Output: True
print(Account("1001")) # Output: Account(number='1001', balance=0.0)
Four lines replace thirteen and do slightly more. The decorator reads the annotated fields and
generates __init__, __repr__ and __eq__ from them, in order,
with defaults working exactly as they do in a function.
The annotations are load-bearing here — that is how the decorator finds the fields. A field with no annotation is invisible to it. This is the one place a type hint changes behaviour rather than just documenting it.
frozen, and the mutable default
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Transaction:
id: int
amount: float
tags: list[str] = field(default_factory=list)
t = Transaction(1, 100.0)
print(t.tags) # Output: []
try:
t.amount = 999
except Exception as error:
print(type(error).__name__) # Output: FrozenInstanceError
frozen=True makes instances immutable, which also makes them hashable — so they can go
in a set or be a dict key. Use it for anything that represents a fact rather than a thing that changes:
a transaction has happened and will not un-happen.
field(default_factory=list) is how you get a mutable default safely. A plain
tags: list[str] = [] is the shared-default bug from
Functions, and dataclasses refuse it outright with a
ValueError rather than letting you ship it.
Validation with __post_init__
from dataclasses import dataclass
@dataclass
class Account:
number: str
balance: float = 0.0
def __post_init__(self):
if self.balance < 0:
raise ValueError("balance cannot be negative")
self.number = self.number.strip()
print(Account(" 1001 ").number) # Output: 1001
try:
Account("1001", -5)
except ValueError as error:
print(error) # Output: balance cannot be negative
The generated __init__ calls __post_init__ after setting the fields, which
is where validation and normalisation go. Doing it here means an invalid instance cannot exist anywhere
in the program — much stronger than checking at each call site.
In real code
The bank app's model layer is dataclasses throughout. This is its transaction record:
@dataclass(frozen=True)
class Transaction:
id: int
account_id: int
type: TransactionType
amount: Decimal
balance_after: Decimal
timestamp: datetime
description: str
@property
def signed_amount(self) -> Decimal:
return self.type.signed(self.amount)
Seven fields, frozen because a statement line never changes once written, and a
@property alongside them — a dataclass is a normal class and takes normal methods.
Type hints
Outside a dataclass, hints do nothing at runtime. They are for readers and for checkers:
def apply_fee(balance: float, fee: float = 2.50) -> float:
return balance - fee
def find(number: str) -> str | None:
return None if number == "missing" else "Checking"
print(apply_fee(100.0)) # Output: 97.5
print(find("missing")) # Output: None
The syntax worth knowing is short: name: type for a parameter or variable,
-> type for the return, | for "either" (3.10 onwards), and builtin generics
for containers — list[str], dict[str, float], tuple[int, ...].
The old typing.List spellings are obsolete since 3.9.
str | None is the one that repays the effort. It is the difference between a value you
can use and one you must check first, and it is the single most common source of
AttributeError: 'NoneType' object has no attribute ....
What a checker adds
Nothing checks hints unless you run a checker. Install mypy and it reads the annotations you already wrote:
pip install mypy
mypy bank/
# bank/services.py:42: error: Argument 1 to "apply_fee" has
# incompatible type "str"; expected "float"
That is the value proposition: a class of bug that would otherwise surface at runtime, in production, caught before the program starts. Editors use the same information for completion and inline warnings, which is a benefit you get without running anything.
The pragmatic approach is to hint function signatures and dataclass fields, and not to bother inside function bodies where the types are obvious. Hints on the boundaries are where nearly all the value is.
Comparing and sorting dataclasses
order=True generates the comparison operators as well, using the fields in declaration
order — the same rule tuples follow:
from dataclasses import dataclass, field, asdict
@dataclass(order=True)
class Account:
kind: str
balance: float
accounts = [Account("Savings", 8400.5), Account("Checking", 1250.0)]
for a in sorted(accounts):
print(a.kind)
# Output: Checking
# Output: Savings
print(asdict(accounts[0])) # Output: {'kind': 'Savings', 'balance': 8400.5}
Field order decides sort order, so put the field you sort by first — or pass
field(compare=False) on the ones that should be ignored.
asdict() converts an instance to a plain dict, recursively, which is how a dataclass
becomes JSON in one step. Its partner replace(obj, balance=0) returns a modified copy,
which is what you use on a frozen instance instead of assigning to it.
When not to use a dataclass
If the class is mostly behaviour with one or two attributes, a plain class is clearer — the dataclass machinery buys you nothing. If it is an immutable record with no methods at all, a NamedTuple is lighter and unpacks. And if you need validation and parsing of external data, Pydantic does at runtime what mypy does statically.
Next: Exception Handling.