Home Tutorials Modern Python

Modern Python

Python Dataclasses: Auto-Generated __init__, __repr__, and More

Pyford Notes July 1, 2026 7 min read
Key points
  • @dataclass auto-generates __init__, __repr__, and __eq__ from annotated fields.
  • Use field(default_factory=list) for mutable defaults — never field(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.asdict() and astuple() The 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.