Home› Tutorials› Modern Python
Modern PythonPython Dataclasses: Auto-Generated __init__, __repr__, and More
@dataclassauto-generates__init__,__repr__, and__eq__from annotated fields.- Use
field(default_factory=list)for mutable defaults — neverfield(default=[]). __post_init__runs after the generated__init__, ideal for cross-field validation.@dataclass(frozen=True)makes instances immutable and hashable (usable as dict keys).
The boilerplate problem
A plain Python class that stores data and compares equal when its fields match requires writing the same three methods every time. For a class with five fields, that is 20 or more lines of mechanical code before any actual logic appears:
Before — manual boilerplate class:
class Point:
def __init__(self, x: float, y: float, label: str = ""):
self.x = x
self.y = y
self.label = label
def __repr__(self):
return f"Point(x={self.x!r}, y={self.y!r}, label={self.label!r})"
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return (self.x, self.y, self.label) == (other.x, other.y, other.label)
p = Point(1.0, 2.0)
print(p) # Point(x=1.0, y=2.0, label='')
print(p == Point(1.0, 2.0)) # True
After — the same class as a dataclass:
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
label: str = ""
p = Point(1.0, 2.0)
print(p) # Point(x=1.0, y=2.0, label='')
print(p == Point(1.0, 2.0)) # True
The decorator reads the class-level type annotations and generates __init__, __repr__, and __eq__ for you. No logic changes, no behaviour changes — just less code to write and maintain.
A basic @dataclass
Every annotated field becomes a parameter of the generated __init__, in the order they appear. Fields with defaults must come after fields without:
from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
in_stock: bool = True
category: str = "general"
item = Product("Widget", 9.99)
print(item)
# Product(name='Widget', price=9.99, in_stock=True, category='general')
# All generated methods work immediately
item2 = Product("Widget", 9.99)
print(item == item2) # True
# repr is readable in the REPL and logging
products = [Product("A", 1.0), Product("B", 2.0, in_stock=False)]
print(products)
Defaults and field()
For simple immutable defaults (strings, numbers, booleans), assign the value directly in the annotation. For mutable defaults like lists or dicts, use field(default_factory=...). Using a bare mutable object as a default would share one instance across all instances of the class — a classic Python pitfall:
from dataclasses import dataclass, field
from typing import List
@dataclass
class ShoppingCart:
owner: str
items: List[str] = field(default_factory=list) # correct
# items: List[str] = [] would raise ValueError
cart_a = ShoppingCart("Alice")
cart_b = ShoppingCart("Bob")
cart_a.items.append("apple")
print(cart_a.items) # ["apple"]
print(cart_b.items) # [] (separate list, not shared)
field() also accepts repr=False to hide sensitive values from the auto-generated __repr__, compare=False to exclude a field from equality checks, and init=False to skip a field in __init__ entirely.
__post_init__ for validation
The generated __init__ calls __post_init__ at the end if you define it. This is where cross-field validation and derived attribute setup belong:
from dataclasses import dataclass, field
import math
@dataclass
class Circle:
radius: float
area: float = field(init=False, repr=False)
def __post_init__(self):
if self.radius <= 0:
raise ValueError(f"radius must be positive, got {self.radius}")
self.area = math.pi * self.radius ** 2
c = Circle(5.0)
print(c.radius) # 5.0
print(f"{c.area:.2f}") # 78.54
# Circle(-1) # raises ValueError
Notice that area is marked init=False so callers cannot pass it as an argument, and repr=False so it does not clutter the printed representation. __post_init__ sets it from radius once we know the value is valid.
frozen=True: immutable instances
Passing frozen=True to the decorator prevents attribute assignment after construction. The generated class also becomes hashable, which means frozen dataclass instances can serve as dictionary keys or set members:
from dataclasses import dataclass
@dataclass(frozen=True)
class Coordinate:
lat: float
lon: float
home = Coordinate(51.5074, -0.1278)
# home.lat = 0.0 # raises FrozenInstanceError
# Hashable: usable as a dict key
visited = {home: "London"}
print(visited[Coordinate(51.5074, -0.1278)]) # London
# Usable inside a set
waypoints = {Coordinate(51.5, -0.1), Coordinate(48.8, 2.35)}
Equality and ordering
By default @dataclass generates __eq__ that compares all fields in definition order. To also generate __lt__, __le__, __gt__, __ge__ for sorting, pass order=True:
from dataclasses import dataclass
@dataclass(order=True)
class Version:
major: int
minor: int
patch: int
v1 = Version(1, 2, 3)
v2 = Version(1, 3, 0)
print(v1 < v2) # True (compares as tuple (1,2,3) < (1,3,0))
releases = [Version(2,0,0), Version(1,9,1), Version(1,2,3)]
print(sorted(releases))
# [Version(major=1, minor=2, patch=3), Version(major=1, minor=9, patch=1), Version(major=2, minor=0, patch=0)]
Q: dataclass vs NamedTuple vs TypedDict?
All three provide typed, structured data containers, but they differ in mutability, inheritance behaviour, and intended use:
NamedTuple produces a tuple subclass: instances are immutable, indexable by position, and unpack like tuples. Use it when you need tuple semantics or interoperability with code that expects sequences.
dataclass produces a normal class: mutable by default, no positional indexing, supports inheritance naturally. Use it for most data-container work where you want methods and optional mutability.
TypedDict is a plain dict at runtime with type annotations for static analysis only. Use it when consuming or producing JSON-like data where dict serialisation is required and you do not want attribute-style access.
from typing import NamedTuple, TypedDict
from dataclasses import dataclass
class PointNT(NamedTuple):
x: float
y: float
class PointDC:
x: float
y: float
class PointTD(TypedDict):
x: float
y: float
nt = PointNT(1.0, 2.0)
print(nt[0]) # 1.0 (tuple indexing)
# nt.x = 3.0 # AttributeError: immutable
dataclasses module ships two helpers for serialisation. dataclasses.asdict(obj) recursively converts a dataclass to a dict, and dataclasses.astuple(obj) converts it to a nested tuple. Both are useful when passing dataclass data to json.dumps() or CSV writers that expect plain Python types.