What you'll learn
- Declare a data-holding class in a few lines with
@dataclassand get__init__,__repr__, and__eq__generated - Use
field()for defaults that are lists or dictionaries, andfrozen=Truefor immutable, hashable values - Add ordering,
__post_init__validation, and convert to a dictionary withasdict - Name a fixed set of choices with
Enuminstead of loose strings
Concept
The previous lesson showed how much code a well-behaved value class needs: __init__ assigning every field, a __repr__, an __eq__, a __hash__. That boilerplate is mechanical, and dataclasses generates it from a list of annotated fields. Write the class body as name: type lines, decorate with @dataclass, and the methods appear. The generated code is ordinary Python you could have written; the decorator saves you from writing it, and from getting it subtly wrong.

Fields can have defaults, and the rules follow function parameters: fields with defaults come after fields without. A mutable default like [] is refused, for the same shared-list reason as function defaults; write field(default_factory=list) instead, which calls list() for each instance. frozen=True makes instances immutable, assignment raises, and makes them hashable from their fields, so a frozen dataclass is the right way to write a value object that goes in sets and dictionaries. order=True generates the comparison methods so instances sort by their fields in order.
| Option / helper | Effect |
|---|---|
field(default_factory=list) |
a fresh list per instance |
frozen=True |
immutable and hashable |
order=True |
<, <=, >, >= comparing fields in declaration order |
slots=True |
less memory, faster attribute access, no ad-hoc attributes |
kw_only=True |
all fields must be passed by keyword |
__post_init__(self) |
runs after the generated __init__; the place to validate |
asdict(obj) / astuple(obj) |
convert to plain data, recursively |
replace(obj, field=value) |
a copy with some fields changed |
Validation goes in __post_init__, which the generated __init__ calls last; raise there if a field is out of range. asdict turns an instance into a nested dictionary, which is the bridge to JSON, and replace gives you a modified copy of a frozen instance. Dataclasses are for classes that are mostly data with a little behavior; a class whose identity is its methods and whose state is private is still an ordinary class.
An Enum is a set of named constants: Status.PAID, Status.REFUNDED. Compared with strings, an enum cannot be misspelled without an AttributeError, has a fixed and discoverable set of members, and is its own type so Status.PAID == "paid" is False and a type checker can see the difference. Members compare by identity, work as dictionary keys, and iterate in definition order. StrEnum and IntEnum make members also behave as their underlying string or integer for the cases where you need to serialize them.

Code
A dataclass and the methods it generates, next to what a hand-written version needed. Defaults, repr, and equality all come free.
# basics.py
from dataclasses import dataclass, field
@dataclass
class Money:
amount: float
currency: str = "EUR"
@dataclass
class Order:
order_id: int
lines: list = field(default_factory=list)
note: str = ""
m = Money(12.5)
print(m)
print(m == Money(12.5, "EUR"), m == Money(12.5, "USD"))
o1, o2 = Order(1), Order(2)
o1.lines.append("pen")
print(o1, o2.lines)
print(Order.__init__.__doc__ is None, hasattr(Order, "__eq__"))
Money(amount=12.5, currency='EUR')
True False
Order(order_id=1, lines=['pen'], note='') []
True TrueFrozen for hashable values, ordering for sorting, and __post_init__ for validation. Assigning to a frozen instance raises; a negative amount is rejected at construction.
# frozen_order.py
from dataclasses import dataclass
@dataclass(frozen=True, order=True)
class Version:
major: int
minor: int = 0
patch: int = 0
def __post_init__(self):
if min(self.major, self.minor, self.patch) < 0:
raise ValueError("version parts must be non-negative")
versions = [Version(3, 14, 7), Version(3, 12), Version(3, 14, 1)]
print(sorted(versions))
print(max(versions), Version(3, 12) < Version(3, 14))
print(len({Version(1), Version(1), Version(2)}))
try:
Version(1, -1)
except ValueError as err:
print("rejected:", err)
v = Version(1)
v.major = 2
[Version(major=3, minor=12, patch=0), Version(major=3, minor=14, patch=1), Version(major=3, minor=14, patch=7)]
Version(major=3, minor=14, patch=7) True
2
rejected: version parts must be non-negative
Traceback (most recent call last):
File "frozen_order.py", line 26, in <module>
v.major = 2
^^^^^^^
File "<string>", line 34, in __setattr__
dataclasses.FrozenInstanceError: cannot assign to field 'major'Converting to plain data and back, and modifying a frozen instance with replace. asdict recurses into nested dataclasses, which is the path to JSON.
# convert.py
import json
from dataclasses import asdict, dataclass, field, replace
@dataclass(frozen=True)
class Money:
amount: float
currency: str = "EUR"
@dataclass
class Invoice:
number: int
total: Money
tags: list[str] = field(default_factory=list)
inv = Invoice(7, Money(99.5), ["urgent"])
data = asdict(inv)
print(data)
print(json.dumps(data))
restored = Invoice(data["number"], Money(**data["total"]), data["tags"])
print(restored == inv)
print(replace(inv.total, currency="USD"), inv.total)
{'number': 7, 'total': {'amount': 99.5, 'currency': 'EUR'}, 'tags': ['urgent']}
{"number": 7, "total": {"amount": 99.5, "currency": "EUR"}, "tags": ["urgent"]}
True
Money(amount=99.5, currency='USD') Money(amount=99.5, currency='EUR')Enums: members, identity, iteration, lookup by value, and a StrEnum that serializes cleanly. A typo is an error, not a silent bug.
# enums.py
from enum import Enum, StrEnum, auto
class Status(Enum):
PENDING = auto()
PAID = auto()
REFUNDED = auto()
class Level(StrEnum):
INFO = "info"
ERROR = "error"
s = Status.PAID
print(s, s.name, s.value)
print(s is Status.PAID, s == "PAID", Status["REFUNDED"], Status(1))
print([member.name for member in Status])
print(Level.ERROR == "error", f"level={Level.ERROR}", Level("info"))
counts = {Status.PAID: 2, Status.PENDING: 1}
print(counts[Status.PAID])
try:
Status.PIAD
except AttributeError as err:
print("typo caught immediately:", err)
Status.PAID PAID 2
True False Status.REFUNDED Status.PENDING
['PENDING', 'PAID', 'REFUNDED']
True level=error info
2
typo caught immediately: type object 'Status' has no attribute 'PIAD'Try it yourself
Create library.py with a frozen, ordered Isbn dataclass wrapping a string with validation in __post_init__, a Book dataclass with title, author, Isbn, a Genre enum, and a tags list with a default factory. Create four books, sort them by title using a key, group them by genre into a dictionary keyed by the enum, dump one to JSON via asdict (converting the enum to its name), and confirm two books with the same fields are equal.
Common mistakes
tags: list = []as a default. The decorator refuses it; usefield(default_factory=list).- Using a dataclass for everything. A class that is mostly behavior with private state is a normal class; dataclasses are for data with a little behavior.
- Comparing an enum to a string.
Status.PAID == "PAID"isFalse; compare members to members, or useStrEnumwhen string equality is genuinely wanted.
What's next
@dataclassgenerates__init__,__repr__, and__eq__from annotated fields;field(default_factory=...)handles mutable defaults.frozen=Truegives immutable, hashable values;order=Truegives sorting;__post_init__validates;asdictandreplaceconvert and copy.Enumreplaces loose strings with a fixed, typed set of named members;StrEnumwhen you need string behavior too.
Your data has shape now. Next you will take it apart with structural pattern matching, which reads dataclasses, enums, and nested data as patterns.