Skip to content

Dataclasses

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
@dataclass
class Point:
x: float
y: float

Python generates:

  • __init__(self, x: float, y: float) that sets self.x and self.y
  • __repr__ that returns "Point(x=1.0, y=2.0)"
  • __eq__ that compares all fields

Scalar defaults work like keyword arguments:

@dataclass
class Config:
host: str = "localhost"
port: int = 8080
debug: bool = False

field(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:

@dataclass
class BadInventory:
name: str
tags: list[str] = [] # mutable default -- dataclass refuses this at CLASS-DEFINITION time

Just 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
@dataclass
class Inventory:
name: str
tags: list[str] = field(default_factory=list)
quantity: int = 0

field also accepts:

ParameterEffect
default_factoryCalled with no arguments to produce each instance’s default
repr=FalseExclude the field from __repr__
compare=FalseExcluded from __eq__ (and from the ordering methods if you also set order=True)
init=FalseExclude 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: int

red.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}")

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 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 order

Version(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.

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 COPY
print(person.address.city) # original untouched -- asdict deep-copies nested dataclasses

This 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:

@dataclass
class 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:

@dataclassNamedTupleTypedDictpydantic BaseModel
Runtime objectRegular class instancetuple subclass (indexable, unpacks)Plain dict — no runtime type at allRegular class instance
MutableYes, unless frozen=TrueNo — tuples are immutableWhatever a dict allowsYes, unless configured frozen
Runtime validationNone — annotations are not enforcedNoneNoneYes — parses and coerces on construction
Best forGeneral-purpose value/record classes with methodsFixed-shape data that should also behave like a tupleThe 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.

@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.

A dataclass is still a normal class — add any methods you need:

@dataclass
class Point:
x: float
y: float
def distance_from_origin(self) -> float:
return (self.x ** 2 + self.y ** 2) ** 0.5
from dataclasses import dataclass, field, asdict, FrozenInstanceError
@dataclass
class Point:
x: float
y: float
def distance_from_origin(self) -> float:
return float((self.x**2 + self.y**2) ** 0.5)
@dataclass
class 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 = 128
except 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 rejected
except TypeError as e:
print(f"TypeError: {e}")
@dataclass
class Address:
city: str
@dataclass
class Person:
name: str
address: Address
person = Person("Ada", Address("London"))
snapshot = asdict(person)
snapshot["address"]["city"] = "Paris" # mutating the COPY
print(person.address.city) # original untouched -- asdict deep-copies nested dataclasses
print(snapshot["address"]["city"])
@dataclass
class 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)))
@dataclass
class 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__)
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 a class decorator that reads annotations once and writes methods onto the class

@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.

What you getBenefitCost
@dataclass (defaults)__init__/__repr__/__eq__ for freeInstances are unhashable unless you add frozen or unsafe_hash
field(default_factory=...)Every instance gets its own mutable defaultSlightly more verbose than a bare literal default
frozen=TrueImmutable, hashable, safe as a dict keyAny field you want to compute after __init__ needs object.__setattr__
slots=TrueLower memory, faster attribute accessNo dynamic extra attributes; more constrained multiple inheritance
order=TrueFree </<=/>/>= from field orderComparing dataclasses of different, unrelated shapes still just compares tuples of values — no semantic check
pydantic BaseModel insteadReal runtime validation and coercionAn extra dependency, and a different (heavier) object than a plain dataclass
  • 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 — @dataclass raises ValueError the moment the class body runs, before any instance exists.
  • frozen=True means 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=True or unsafe_hash=True are required to opt in.
  • asdict just returns vars(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.

Before trusting a generated dataclass, check for:

  • A field with a literal mutable default (tags: list = [], meta: dict = {}) instead of field(default_factory=...) — this doesn’t run at all; @dataclass raises ValueError immediately.
  • A frozen=True class with a plain self.field = value assignment anywhere outside __post_init__’s deliberate object.__setattr__ — that’s a FrozenInstanceError waiting to happen.
  • A dataclass used as a dict key or set member with no frozen=True/unsafe_hash=True — defaults to unhashable, TypeError at the point of use.
  • unsafe_hash=True on 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 of asdict(), missing the deep-copy semantics and leaking references to the original nested objects.

🐛 Spot the bug

from dataclasses import dataclass, field
@dataclass
class 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).

Which dunder methods does @dataclass generate automatically?
Why can't you write tags: list = [] as a dataclass field default?
What does frozen=True add to a dataclass?
What is field(repr=False) used for?
What happens on the last line?
@dataclass(frozen=True)
class Point:
    x: float
    y: float

p = Point(1.0, 2.0)
p.x = 5.0
What happens?
@dataclass(order=True, kw_only=True)
class Version:
    major: int
    minor: int

Version(1, 2)
What does this print?
person = Person("Ada", Address("London"))
snapshot = asdict(person)
snapshot["address"]["city"] = "Paris"
print(person.address.city)
You're modeling data that arrives as untrusted JSON from an external API and must be validated before use. Which is the best fit?