Most classes are mostly data: a handful of attributes, a few helper methods that work with them. Writing the __init__, __repr__, and __eq__ by hand for every record class gets tedious and error-prone. The @dataclass decorator from the dataclasses module generates all of that boilerplate from a few annotated fields.
@dataclass turns a class body that looks like a typed struct into a full class with __init__, __repr__, and field-based __eq__ for free. You keep the methods you want to add, skip the ones the decorator writes. The result is short, readable, and typed — exactly what you want for an entity class.
Several dataclass options matter in practice. frozen=True makes instances immutable (and hashable), which is what you want for value objects and dict keys. order=True adds comparison operators so you can sort instances. slots=True (Python 3.10+) skips the per-instance __dict__ for smaller memory and faster attribute access.
Dataclasses interoperate cleanly with the rest of the language. You can inherit from one, mix them with normal classes, convert them to dicts with asdict, and compare them with ==. For the majority of value-oriented classes, they are the right default.
Field declarations and defaults
A dataclass field is a type-annotated class-level name. Defaults are allowed (count: int = 0); mutable defaults use field(default_factory=list). For fields that should not be part of __init__, use field(init=False, default=...).
Inheritance: subclasses add fields at the end; if you need a required field in a subclass, the parent must either have no fields without defaults or you override __init__ yourself. Modern Python relaxes some of these rules via kw_only=True.
frozen, slots, and methods
Methods are just normal methods on the class. They appear in the generated __repr__? No — only annotated fields show up there. frozen=True prevents attribute assignment after construction; any such attempt raises FrozenInstanceError. slots=True adds __slots__ automatically, trading some flexibility for speed.
dataclasses.replace(obj, field=value) is the immutable-update idiom: it returns a new instance with one field changed, leaving the original intact.
The dataclass API.
| Tool | Purpose |
|---|---|
@dataclassdecorator | Generates __init__, __repr__, __eq__. |
field(default_factory=list)function | Per-instance mutable default. |
@dataclass(frozen=True)decorator | Immutable, hashable dataclass. |
@dataclass(order=True)decorator | Adds <, <=, >, >= based on fields. |
replace(obj, field=v)function | Returns a copy with field changed. |
asdict(obj)function | Converts to a nested dict. |
@dataclass(slots=True)decorator | Uses __slots__ for memory/speed. |
ClassVar[T]annotation | Excludes a field from __init__/dataclass fields. |
Organizing Data and Behavior code example
The script demonstrates mutable and frozen dataclasses, default factories, and the replace idiom.
# Lesson: Organizing Data and Behavior
from dataclasses import asdict, dataclass, field, replace
from typing import ClassVar
@dataclass(order=True)
class Task:
priority: int
title: str = ""
tags: list[str] = field(default_factory=list)
next_id: ClassVar[int] = 0
id: int = field(init=False)
def __post_init__(self) -> None:
Task.next_id += 1
self.id = Task.next_id
@dataclass(frozen=True)
class Money:
amount: float
currency: str = "EUR"
a = Task(1, "write docs", tags=["docs"])
b = Task(3, "ship release")
c = Task(2, "review PRs", tags=["review"])
print(a); print(b); print(c)
print("sorted by priority:", sorted([a, b, c]))
print("asdict(a):", asdict(a))
# Immutable update via replace
a2 = replace(a, title="write more docs")
print("a unchanged:", a.title)
print("a2 updated :", a2.title)
# Frozen dataclasses are hashable and safe as dict keys
prices = {Money(10): "pen", Money(20): "pencil"}
print("prices:", prices[Money(10)])
try:
Money(10).amount = 99 # type: ignore[misc]
except Exception as err:
print("frozen:", type(err).__name__, err)
Three dataclass features to absorb:
1) `order=True` makes Task instances sortable by their field tuple.
2) `field(init=False)` + `__post_init__` is the auto-generated id pattern.
3) `replace` is the immutable-update idiom; originals are never mutated.
4) `frozen=True` dataclasses work as dict keys because they are hashable.
Design a small value object.
from dataclasses import dataclass
@dataclass(frozen=True, order=True)
class Version:
major: int
minor: int
patch: int = 0
def __str__(self) -> str:
return f"{self.major}.{self.minor}.{self.patch}"
vs = [Version(1, 10), Version(1, 2), Version(2, 0)]
for v in sorted(vs):
print(v)
Verify field-based equality and replace.
from dataclasses import dataclass, replace
@dataclass
class P:
x: int
y: int
assert P(1, 2) == P(1, 2)
assert replace(P(1, 2), y=9) == P(1, 9)
assert P(1, 2) != P(1, 3)
Running prints:
Task(priority=1, title='write docs', tags=['docs'], id=1)
Task(priority=3, title='ship release', tags=[], id=2)
Task(priority=2, title='review PRs', tags=['review'], id=3)
sorted by priority: [Task(priority=1, ...), Task(priority=2, ...), Task(priority=3, ...)]
asdict(a): {'priority': 1, 'title': 'write docs', 'tags': ['docs'], 'id': 1}
a unchanged: write docs
a2 updated : write more docs
prices: pen
frozen: FrozenInstanceError cannot assign to field 'amount'