Dataclasses
A decorator that writes the boilerplate for classes that mostly hold data — which is most classes.
Most classes exist to hold a few related values. Written by hand that is a lot of repetition:
class Point:
def __init__(self, x: float, y: float):
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)from dataclasses import dataclass
@dataclass
class Point:
x: float
y: floatIdentical behaviour. The decorator reads the annotations and generates __init__, __repr__ and __eq__.
p = Point(1.0, 2.0)
print(p) # Point(x=1.0, y=2.0)
p == Point(1.0, 2.0) # TrueThat __repr__ alone justifies the decorator — a hand-written class prints as <__main__.Point object at 0x10d4f2>, which tells you nothing in a log or a debugger.
Defaults#
from dataclasses import dataclass, field
@dataclass
class Config:
host: str # required
port: int = 8080 # optional
tags: list[str] = field(default_factory=list) # mutable — MUST use a factory
created: datetime = field(default_factory=lambda: datetime.now(UTC))Config("localhost") # Config(host='localhost', port=8080, tags=[], ...)As with function parameters, fields with defaults must come after those without.
Frozen — immutable instances#
@dataclass(frozen=True)
class Money:
amount_cents: int
currency: str = "USD"
m = Money(1999)
m.amount_cents = 0 # FrozenInstanceErrorfrozen=True also makes the instance hashable, so it works as a dictionary key or in a set:
prices = {Money(1999): "standard", Money(2999): "premium"}Use frozen by default for value objects — anything representing a measurement, an identifier, a coordinate or an amount. Mutability should be a decision, not an accident.
Validation#
__post_init__ runs after the generated __init__:
@dataclass(frozen=True)
class Money:
amount_cents: int
currency: str = "USD"
def __post_init__(self):
if self.amount_cents < 0:
raise ValueError("amount cannot be negative")
if len(self.currency) != 3:
raise ValueError(f"currency must be a 3-letter code, got {self.currency!r}")Now an invalid Money cannot exist. Enforcing invariants in the constructor is what makes a value type trustworthy everywhere else in the program.
For a frozen class, use object.__setattr__ if you must normalise a field:
def __post_init__(self):
object.__setattr__(self, "currency", self.currency.upper())Useful options#
@dataclass(frozen=True, slots=True, kw_only=True, order=True)
class Event:
timestamp: datetime
name: str
payload: dict[str, str] = field(default_factory=dict, compare=False, repr=False)| Option | Effect |
|---|---|
frozen=True | immutable and hashable |
slots=True | faster attribute access, less memory, no accidental new attributes |
kw_only=True | callers must use keyword arguments — good for wide classes |
order=True | generates <, <=, >, >= for sorting |
And per field:
field(...) | Effect |
|---|---|
default_factory= | a fresh value per instance |
compare=False | excluded from == and ordering |
repr=False | hidden from __repr__ — use for secrets and large blobs |
init=False | not a constructor parameter; set in __post_init__ |
repr=False deserves a mention: a dataclass holding a password hash or an API key will print it in every log line and traceback unless you exclude it.
Methods and inheritance#
A dataclass is an ordinary class. Add whatever you like:
@dataclass(frozen=True)
class Money:
amount_cents: int
currency: str = "USD"
def __add__(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError("cannot add different currencies")
return Money(self.amount_cents + other.amount_cents, self.currency)
@property
def display(self) -> str:
return f"{self.amount_cents / 100:.2f} {self.currency}"
@classmethod
def from_string(cls, s: str) -> "Money":
return cls(int(round(float(s) * 100)))Helpers#
from dataclasses import asdict, astuple, replace, fields
asdict(point) # {"x": 1.0, "y": 2.0} — recursive
astuple(point) # (1.0, 2.0)
replace(point, x=5.0) # a NEW instance with one field changed
[f.name for f in fields(Point)]replace is how you "modify" a frozen instance — it returns a copy, leaving the original alone.
What to use when#
| Use | When |
|---|---|
dict | shape is genuinely dynamic, or it is just JSON passing through |
NamedTuple | a small immutable record you want to unpack like a tuple |
@dataclass | the default for structured data you own |
pydantic model | data crossing a boundary that needs runtime validation |
| plain class | behaviour-heavy, with little state |
The distinction that matters most: a dataclass does not validate types at runtime. Point("a", "b") constructs happily — the annotations are hints, checked by mypy and ignored by Python. For data arriving from a request or a config file, use a validating library so something actually verifies it.
Exercise#
from dataclasses import dataclass, field
# Build a frozen `Order` dataclass with:
# - id: str, customer: str
# - lines: list of (sku, qty, unit_cents) tuples, defaulting to empty
# - a `total_cents` property
# - __post_init__ rejecting an empty id
# - the lines field excluded from __repr__
# Then create one, print it, and use replace() to change the customer.
# write your code hereCommon questions#
Dataclass or pydantic?#
Dataclass for internal data you construct yourself — it is standard library, has no dependency and no runtime overhead. Pydantic when data arrives from outside and must be validated and coerced, which dataclasses do not do.
Should I use slots=True?#
Usually yes for classes you create many of — it reduces memory and speeds attribute access. The trade-offs are that you cannot add attributes dynamically and multiple inheritance gets fiddlier, neither of which matters for a typical value object.
Why did my mutable default raise an error?#
Because a bare [] or {} as a class-level default would be shared by every instance. Dataclasses detect this and refuse, which is safer than the silent sharing you get from a hand-written __init__. Use field(default_factory=list).
Get the Python agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for Python. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.