Python Dataclasses
OOP Modern Tools
Dataclasses
`@dataclass` reduces boilerplate for classes that primarily store data.
What @dataclass generates
A class that mostly stores data needs the same three methods every time. Written by hand:
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 other.__class__ is self.__class__:
return (self.x, self.y) == (other.x, other.y)
return NotImplemented
@dataclass generates all three from the field annotations:
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
p = Point(1, 2)
print(p) # -> Point(x=1, y=2)
print(p == Point(1, 2)) # -> True
The annotations declare the fields — in order, they become the __init__ parameters. Like all type hints, they are not enforced at runtime: Point('a', 'b') constructs without complaint.
The decorator only adds methods you haven't written. The result is still an ordinary class: define regular methods, use inheritance, override the generated __repr__ with your own — a hand-written method always wins over the generated one.
Defaults and default_factory
Immutable defaults work as plain assignments. A bare mutable default is rejected outright, because it would be shared across every instance:
@dataclass
class Basket:
items: list = []
# ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory
Do what the error says — default_factory calls the function once per instance:
from dataclasses import dataclass, field
@dataclass
class Basket:
items: list = field(default_factory=list)
a, b = Basket(), Basket()
a.items.append('apple')
print(a.items) # -> ['apple']
print(b.items) # -> []
The factory can be any zero-argument callable, which also solves "default is computed at creation time":
from datetime import datetime, timezone
@dataclass
class Invoice:
total: float
customer: str
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
Post-init processing
Use __post_init__ for validation or derived fields:
@dataclass
class Order:
quantity: int
unit_price: float
total: float = field(init=False)
def __post_init__(self):
if self.quantity <= 0:
raise ValueError("quantity must be positive")
self.total = self.quantity * self.unit_price
order = Order(quantity=3, unit_price=9.99)
print(order.total) # -> 29.97
field(init=False) keeps total out of the constructor signature; __post_init__ runs right after the generated __init__ and fills it in.
Frozen and ordered dataclasses
frozen=True makes instances immutable — assignment raises FrozenInstanceError — and, combined with the generated __eq__, makes them hashable, so they work as dict keys and set members:
@dataclass(frozen=True)
class Config:
host: str
port: int
cfg = Config('localhost', 5432)
cfg.port = 5433
# dataclasses.FrozenInstanceError: cannot assign to field 'port'
order=True adds <, <=, >, >=, comparing field by field in declaration order:
@dataclass(order=True)
class Version:
major: int
minor: int
print(Version(3, 10) < Version(3, 12)) # -> True
print(sorted([Version(3, 12), Version(2, 7)]))
# -> [Version(major=2, minor=7), Version(major=3, minor=12)]
Two more options worth knowing: kw_only=True makes every field keyword-only in __init__, and slots=True (Python 3.10+) generates a __slots__ class, cutting per-instance memory when you create many objects.
When a dataclass is the wrong tool
- Plain class — when behavior dominates and the attributes are an implementation detail, generated
__eq__/__repr__add nothing. NamedTuple— when you want tuple behavior: unpacking, indexing, comparison with plain tuples, immutability by construction.TypedDict— when the data genuinely is a dict (JSON payloads,**kwargs) and you only want the type checker to know its shape.
For the JSON boundary specifically, dataclasses still cooperate: dataclasses.asdict(instance) converts an instance — recursively, including nested dataclasses — into a plain dict ready for serialization.
Frequently Asked Questions
Dataclass vs NamedTuple vs dict — which should I use?
Use a dataclass when you want a mutable (or optionally frozen) object with named fields, methods, and defaults. Use NamedTuple when the value is conceptually a tuple — it unpacks, indexes, and compares equal to plain tuples. Use a dict (with TypedDict for typing) when keys are dynamic or the data arrives as JSON and converting it buys you nothing.
from typing import NamedTuple
class PointT(NamedTuple):
x: int
y: int
print(PointT(1, 2) == (1, 2)) # -> True, it is a tuple
Why default_factory instead of items: list = []?
A bare mutable default raises ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory. Python evaluates the default once, so every instance would share the same list — the same trap as mutable default arguments in functions. default_factory=list builds a fresh list per instance.
Do dataclasses work with inheritance?
Yes. A dataclass can subclass another dataclass; fields combine in base-first order, so the subclass __init__ takes the base fields first. One rule to watch: fields without defaults cannot follow fields with defaults, including inherited ones — if the base class defines defaults, every subclass field needs a default too.
@dataclass
class Base:
id: int
@dataclass
class User(Base):
name: str = 'anon'
print(User(1, 'ada')) # -> User(id=1, name='ada')