Skip to main content

Python Dataclasses

OOP Modern Tools

Dataclasses

`@dataclass` reduces boilerplate for classes that primarily store data.

What @dataclass generates

A class that mostly stores data needs the same three methods every time. Written by hand:

class Point:
def __init__(self, x, y):
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 other.__class__ is self.__class__:
return (self.x, self.y) == (other.x, other.y)
return NotImplemented

@dataclass generates all three from the field annotations:

from dataclasses import dataclass

@dataclass
class Point:
x: int
y: int

p = Point(1, 2)
print(p) # -> Point(x=1, y=2)
print(p == Point(1, 2)) # -> True

The annotations declare the fields — in order, they become the __init__ parameters. Like all type hints, they are not enforced at runtime: Point('a', 'b') constructs without complaint.

The decorator only adds methods you haven't written. The result is still an ordinary class: define regular methods, use inheritance, override the generated __repr__ with your own — a hand-written method always wins over the generated one.

Defaults and default_factory

Immutable defaults work as plain assignments. A bare mutable default is rejected outright, because it would be shared across every instance:

@dataclass
class Basket:
items: list = []
# ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory

Do what the error says — default_factory calls the function once per instance:

from dataclasses import dataclass, field

@dataclass
class Basket:
items: list = field(default_factory=list)

a, b = Basket(), Basket()
a.items.append('apple')
print(a.items) # -> ['apple']
print(b.items) # -> []

The factory can be any zero-argument callable, which also solves "default is computed at creation time":

from datetime import datetime, timezone

@dataclass
class Invoice:
total: float
customer: str
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))

Post-init processing

Use __post_init__ for validation or derived fields:

@dataclass
class Order:
quantity: int
unit_price: float
total: float = field(init=False)

def __post_init__(self):
if self.quantity <= 0:
raise ValueError("quantity must be positive")
self.total = self.quantity * self.unit_price

order = Order(quantity=3, unit_price=9.99)
print(order.total) # -> 29.97

field(init=False) keeps total out of the constructor signature; __post_init__ runs right after the generated __init__ and fills it in.

Frozen and ordered dataclasses

frozen=True makes instances immutable — assignment raises FrozenInstanceError — and, combined with the generated __eq__, makes them hashable, so they work as dict keys and set members:

@dataclass(frozen=True)
class Config:
host: str
port: int

cfg = Config('localhost', 5432)
cfg.port = 5433
# dataclasses.FrozenInstanceError: cannot assign to field 'port'

order=True adds <, <=, >, >=, comparing field by field in declaration order:

@dataclass(order=True)
class Version:
major: int
minor: int

print(Version(3, 10) < Version(3, 12)) # -> True
print(sorted([Version(3, 12), Version(2, 7)]))
# -> [Version(major=2, minor=7), Version(major=3, minor=12)]

Two more options worth knowing: kw_only=True makes every field keyword-only in __init__, and slots=True (Python 3.10+) generates a __slots__ class, cutting per-instance memory when you create many objects.

When a dataclass is the wrong tool

  • Plain class — when behavior dominates and the attributes are an implementation detail, generated __eq__/__repr__ add nothing.
  • NamedTuple — when you want tuple behavior: unpacking, indexing, comparison with plain tuples, immutability by construction.
  • TypedDict — when the data genuinely is a dict (JSON payloads, **kwargs) and you only want the type checker to know its shape.

For the JSON boundary specifically, dataclasses still cooperate: dataclasses.asdict(instance) converts an instance — recursively, including nested dataclasses — into a plain dict ready for serialization.

Frequently Asked Questions

Dataclass vs NamedTuple vs dict — which should I use?

Use a dataclass when you want a mutable (or optionally frozen) object with named fields, methods, and defaults. Use NamedTuple when the value is conceptually a tuple — it unpacks, indexes, and compares equal to plain tuples. Use a dict (with TypedDict for typing) when keys are dynamic or the data arrives as JSON and converting it buys you nothing.

from typing import NamedTuple

class PointT(NamedTuple):
x: int
y: int

print(PointT(1, 2) == (1, 2)) # -> True, it is a tuple

Why default_factory instead of items: list = []?

A bare mutable default raises ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory. Python evaluates the default once, so every instance would share the same list — the same trap as mutable default arguments in functions. default_factory=list builds a fresh list per instance.

Do dataclasses work with inheritance?

Yes. A dataclass can subclass another dataclass; fields combine in base-first order, so the subclass __init__ takes the base fields first. One rule to watch: fields without defaults cannot follow fields with defaults, including inherited ones — if the base class defines defaults, every subclass field needs a default too.

@dataclass
class Base:
id: int

@dataclass
class User(Base):
name: str = 'anon'

print(User(1, 'ada')) # -> User(id=1, name='ada')

Next up in your learning path