Dataclasses
The boilerplate problem
Section titled “The boilerplate problem”A class that holds data typically needs __init__, __repr__, and __eq__ — all written by hand with identical logic: copy constructor arguments into self.attr, format attributes into a string, compare attribute by attribute.
@dataclass generates all of this from the class-level type annotations.
from dataclasses import dataclass
@dataclassclass Point: x: float y: floatPython generates:
__init__(self, x: float, y: float)that setsself.xandself.y__repr__that returns"Point(x=1.0, y=2.0)"__eq__that compares all fields
Default values
Section titled “Default values”Scalar defaults work like keyword arguments:
@dataclassclass Config: host: str = "localhost" port: int = 8080 debug: bool = Falsefield(default_factory=list) and the real mutable-default error
Section titled “field(default_factory=list) and the real mutable-default error”You cannot use a mutable object (list, dict) directly as a default — Python would share one instance across all objects.
@dataclass doesn’t just warn about this, it refuses to build the class at all:
@dataclassclass BadInventory: name: str tags: list[str] = [] # mutable default -- dataclass refuses this at CLASS-DEFINITION timeJust defining BadInventory raises ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory — before you ever create an instance. This is stricter than a plain function’s mutable-default argument (covered in the Functions module), which silently works until the shared state bites you; a dataclass field turns the same mistake into an immediate, loud error. The fix is field(default_factory=...):
from dataclasses import dataclass, field
@dataclassclass Inventory: name: str tags: list[str] = field(default_factory=list) quantity: int = 0field also accepts:
| Parameter | Effect |
|---|---|
default_factory | Called with no arguments to produce each instance’s default |
repr=False | Exclude the field from __repr__ |
compare=False | Excluded from __eq__ (and from the ordering methods if you also set order=True) |
init=False | Exclude from __init__; set manually in __post_init__ |
frozen=True + object.__setattr__ in __post_init__
Section titled “frozen=True + object.__setattr__ in __post_init__”frozen=True makes the dataclass immutable: it generates __setattr__ and __delattr__ that raise FrozenInstanceError on any attempt to assign, and it also generates __hash__ so frozen instances can be used as dict keys or in sets.
@dataclass(frozen=True)class Color: red: int green: int blue: intred.red = 128 raises FrozenInstanceError: cannot assign to field 'red', verified on 3.14. That’s a problem the moment you want to compute a derived field once, in __post_init__ — a normal assignment there is blocked too, by design. The escape hatch is object.__setattr__, which bypasses the dataclass’s own __setattr__ override, used deliberately and exactly once during construction:
@dataclass(frozen=True, slots=True)class Color: red: int green: int blue: int hex: str = field(init=False)
def __post_init__(self) -> None: # frozen blocks self.hex = ...; bypass it deliberately, once, during construction object.__setattr__(self, "hex", f"#{self.red:02x}{self.green:02x}{self.blue:02x}")slots=True
Section titled “slots=True”Adding slots=True (3.10+) generates __slots__ for the class, removing the per-instance __dict__. Instances use less memory and attribute access is slightly faster, but you can no longer add attributes that weren’t declared as fields, and multiple inheritance with other __slots__ classes gets more constrained. Color above combines frozen=True and slots=True — a common pairing for small, high-volume value objects.
order=True and kw_only
Section titled “order=True and kw_only”order=True generates __lt__, __le__, __gt__, __ge__ from the field order — without it, < and friends raise TypeError on two dataclass instances. kw_only=True forces every field to be passed by keyword, which is useful once a dataclass has several fields of the same type where positional order is easy to get wrong:
@dataclass(order=True, kw_only=True)class Version: major: int minor: int patch: int = 0
v1 = Version(major=1, minor=2)v2 = Version(major=1, minor=3)print(v1 < v2) # True -- compares (major, minor, patch) tuples in field orderVersion(1, 2) — calling it positionally — raises TypeError: Version.__init__() takes 1 positional argument but 3 were given; both uvx mypy --strict and uvx pyright also reject that call statically (Too many positional arguments for "Version" / Expected 0 positional arguments) — the type checker catches the kw_only violation before you’d ever run the code.
asdict deep-copies nested dataclasses
Section titled “asdict deep-copies nested dataclasses”dataclasses.asdict recursively converts a dataclass (including nested dataclasses) into plain dicts — and it deep-copies as it goes, so mutating the result never touches the original:
snapshot = asdict(person)snapshot["address"]["city"] = "Paris" # mutating the COPYprint(person.address.city) # original untouched -- asdict deep-copies nested dataclassesThis is the opposite of the aliasing behavior plain attribute access gives you — person.address returns the same Address object, but asdict(person)["address"] is an independent dict.
eq=True by default makes instances unhashable — frozen/unsafe_hash fix it
Section titled “eq=True by default makes instances unhashable — frozen/unsafe_hash fix it”@dataclass generates __eq__ by default (eq=True), and — exactly like any hand-written class — generating __eq__ sets __hash__ to None. A plain, non-frozen dataclass is unhashable:
@dataclassclass Plain: x: int y: int
hash(Plain(1, 2)) # TypeError: unhashable type: 'Plain'Two ways to opt back in: frozen=True generates a correct __hash__ from the fields (safe, because a frozen instance can’t change after its hash is computed), or unsafe_hash=True generates one on a mutable dataclass — named “unsafe” because nothing stops you from mutating a field after using the instance as a dict key, which corrupts the dict’s internal hash table.
The dataclass vs NamedTuple vs TypedDict vs pydantic table
Section titled “The dataclass vs NamedTuple vs TypedDict vs pydantic table”All four describe “a bag of named fields”, but they solve different problems:
@dataclass | NamedTuple | TypedDict | pydantic BaseModel | |
|---|---|---|---|---|
| Runtime object | Regular class instance | tuple subclass (indexable, unpacks) | Plain dict — no runtime type at all | Regular class instance |
| Mutable | Yes, unless frozen=True | No — tuples are immutable | Whatever a dict allows | Yes, unless configured frozen |
| Runtime validation | None — annotations are not enforced | None | None | Yes — parses and coerces on construction |
| Best for | General-purpose value/record classes with methods | Fixed-shape data that should also behave like a tuple | The shape of a plain dict you already have (e.g. parsed JSON) | Untrusted input (API requests, config files) that needs real validation |
Reach for @dataclass as the default; NamedTuple when tuple-compatibility (unpacking, indexing) matters; TypedDict when you’re describing a dict you don’t control the construction of; pydantic when data arrives from outside your program and must be validated, not just annotated.
__match_args__ and pattern matching
Section titled “__match_args__ and pattern matching”@dataclass generates __match_args__ from the field order, which makes instances work directly with match/case positional patterns:
match p: case Point2(x=0, y=0): return "origin" case Point2(x=x, y=y): return f"({x}, {y})"Point2.__match_args__ is ('x', 'y') — that’s what lets case Point2(x, y): (positional) work at all, in addition to the keyword form shown above.
Adding custom methods
Section titled “Adding custom methods”A dataclass is still a normal class — add any methods you need:
@dataclassclass Point: x: float y: float
def distance_from_origin(self) -> float: return (self.x ** 2 + self.y ** 2) ** 0.5Full runnable demo
Section titled “Full runnable demo”from dataclasses import dataclass, field, asdict, FrozenInstanceError
@dataclassclass Point: x: float y: float
def distance_from_origin(self) -> float: return float((self.x**2 + self.y**2) ** 0.5)
@dataclassclass Inventory: name: str tags: list[str] = field(default_factory=list) quantity: int = 0
def add_tag(self, tag: str) -> None: self.tags.append(tag)
p = Point(3.0, 4.0)print(p)print(p.distance_from_origin())
item = Inventory("Widget")item.add_tag("sale")item.add_tag("clearance")print(item)
try:
@dataclass class BadInventory: name: str tags: list[str] = [] # mutable default -- dataclass refuses this at CLASS-DEFINITION time
except ValueError as e: print(f"ValueError: {e}")
@dataclass(frozen=True, slots=True)class Color: red: int green: int blue: int hex: str = field(init=False)
def __post_init__(self) -> None: # frozen blocks self.hex = ...; bypass it deliberately, once, during construction object.__setattr__(self, "hex", f"#{self.red:02x}{self.green:02x}{self.blue:02x}")
red = Color(255, 0, 0)print(red)print(red.hex)try: red.red = 128except FrozenInstanceError as e: print(f"FrozenInstanceError: {e}")
print(hash(red) == hash(Color(255, 0, 0)))
@dataclass(order=True, kw_only=True)class Version: major: int minor: int patch: int = 0
v1 = Version(major=1, minor=2)v2 = Version(major=1, minor=3)print(v1 < v2)print(sorted([v2, v1], key=lambda v: (v.major, v.minor, v.patch)))
try: Version(1, 2) # kw_only=True -- positional args are rejectedexcept TypeError as e: print(f"TypeError: {e}")
@dataclassclass Address: city: str
@dataclassclass Person: name: str address: Address
person = Person("Ada", Address("London"))snapshot = asdict(person)snapshot["address"]["city"] = "Paris" # mutating the COPYprint(person.address.city) # original untouched -- asdict deep-copies nested dataclassesprint(snapshot["address"]["city"])
@dataclassclass Plain: x: int y: int
try: hash(Plain(1, 2))except TypeError as e: print(f"TypeError: {e}")
@dataclass(unsafe_hash=True)class UnsafeHashable: x: int y: int
print(hash(UnsafeHashable(1, 2)) == hash(UnsafeHashable(1, 2)))
@dataclassclass Point2: x: float y: float
def describe(point: object) -> str: match point: case Point2(x=0, y=0): return "origin" case Point2(x=x, y=y): return f"({x}, {y})" case _: return "not a point"
print(describe(Point2(0, 0)))print(describe(Point2(1, 2)))print(Point2.__match_args__)Loading Python runtime (first run only)…
Mental Model
Section titled “Mental Model”flowchart LR Ann["class body: name: type = default"] --> Gen["@dataclass reads __annotations__"] Gen --> Init["generates __init__"] Gen --> Repr["generates __repr__"] Gen --> Eq["generates __eq__ (eq=True by default)"] Eq -.->|"sets, unless frozen/unsafe_hash"| Hash["__hash__ = None"] Gen -->|"frozen=True"| Setattr["generates __setattr__/__delattr__ that raise"]
@dataclass is not magic syntax — it’s a regular class decorator that runs once, at class-definition time. It reads the class’s __annotations__ (the name: type lines) and its field(...) calls, and generates plain Python methods (__init__, __repr__, __eq__, and more depending on the flags) directly onto the class, exactly as if you’d written them by hand. That’s why a mutable default raises immediately at definition time — the decorator inspects the defaults while building __init__, before any instance exists — and why frozen=True and eq=True interact: the same decorator call decides both, so it can generate a consistent __hash__ instead of leaving you to reconcile them yourself.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
@dataclass (defaults) | __init__/__repr__/__eq__ for free | Instances are unhashable unless you add frozen or unsafe_hash |
field(default_factory=...) | Every instance gets its own mutable default | Slightly more verbose than a bare literal default |
frozen=True | Immutable, hashable, safe as a dict key | Any field you want to compute after __init__ needs object.__setattr__ |
slots=True | Lower memory, faster attribute access | No dynamic extra attributes; more constrained multiple inheritance |
order=True | Free </<=/>/>= from field order | Comparing dataclasses of different, unrelated shapes still just compares tuples of values — no semantic check |
pydantic BaseModel instead | Real runtime validation and coercion | An extra dependency, and a different (heavier) object than a plain dataclass |
Common Misconceptions
Section titled “Common Misconceptions”- A dataclass with a list field just needs a default like any other.
tags: list = []doesn’t quietly share state like a function’s mutable default would —@dataclassraisesValueErrorthe moment the class body runs, before any instance exists. frozen=Truemeans you can never set fields anywhere, including__post_init__.object.__setattr__(self, name, value)is the documented escape hatch, used to set derived fields exactly once during construction.- Every dataclass can be used as a dict key. Only if it’s hashable — and by default (
eq=True,frozen=False) it isn’t.frozen=Trueorunsafe_hash=Trueare required to opt in. asdictjust returnsvars(instance). It recursively converts nested dataclasses to dicts and deep-copies mutable fields, so mutating the result never affects the original instance.- NamedTuple, TypedDict, and pydantic BaseModel are interchangeable with dataclass — pick whichever is shortest to type. They differ in mutability, runtime identity, and whether anything validates the data — the right choice depends on where the data comes from and what you’ll do with it.
Reading AI-generated code
Section titled “Reading AI-generated code”Before trusting a generated dataclass, check for:
- A field with a literal mutable default (
tags: list = [],meta: dict = {}) instead offield(default_factory=...)— this doesn’t run at all;@dataclassraisesValueErrorimmediately. - A
frozen=Trueclass with a plainself.field = valueassignment anywhere outside__post_init__’s deliberateobject.__setattr__— that’s aFrozenInstanceErrorwaiting to happen. - A dataclass used as a dict key or set member with no
frozen=True/unsafe_hash=True— defaults to unhashable,TypeErrorat the point of use. unsafe_hash=Trueon a dataclass whose fields are mutated after construction — the object’s hash can change while it’s sitting in a set or dict, silently corrupting lookups.- A nested-dataclass structure converted with
vars()or manual dict-building instead ofasdict(), missing the deep-copy semantics and leaking references to the original nested objects.
🐛 Spot the bug
from dataclasses import dataclass, field
@dataclassclass Order: items: list = field(default_factory=list) tags: list = [] # meant as "no tags by default"
def add_tag(self, tag: str) -> None: self.tags.append(tag)Show the answer
tags: list = [] is a literal mutable default on a dataclass field — @dataclass refuses to build the class at all. Verified: importing this module raises ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory at the class Order: line, before Order is ever instantiated. Fix it the same way items above already does it correctly: tags: list = field(default_factory=list).