Skip to content
elephantoo

Dataclasses

Lesson 23 of 38 14 min read

Generate __init__, __repr__ and __eq__ automatically; defaults, field(), __post_init__, frozen, slots and asdict.


Many classes exist mainly to hold data: a Product with a name and price, a Point with x and y, a User with an id and email. Writing __init__, __repr__ and __eq__ for each one is tedious and error-prone. The dataclasses module (standard library, Python 3.7+) generates them from simple type-annotated field declarations.

Before and after#

The hand-written version:

Python
class Product:
    def __init__(self, name, price, quantity=0):
        self.name = name
        self.price = price
        self.quantity = quantity

    def __repr__(self):
        return f"Product(name={self.name!r}, price={self.price!r}, quantity={self.quantity!r})"

    def __eq__(self, other):
        if not isinstance(other, Product):
            return NotImplemented
        return (self.name, self.price, self.quantity) == (other.name, other.price, other.quantity)

The dataclass version:

Python
from dataclasses import dataclass


@dataclass
class Product:
    name: str
    price: float
    quantity: int = 0

    def total_value(self) -> float:        # normal methods work as usual
        return self.price * self.quantity


p = Product("Keyboard", 2499, 3)
print(p)
print(p == Product("Keyboard", 2499, 3))
print(p.total_value())
print(Product("Mouse", 799))
Output
Product(name='Keyboard', price=2499, quantity=3)
True
7497
Product(name='Mouse', price=799, quantity=0)

Each annotated class variable (name: str) becomes a field. The decorator writes an __init__ that takes the fields in order, a readable __repr__, and a value-based __eq__. Fields with defaults must come after fields without.

Type annotations are required to declare fields, but — as everywhere in Python — they're not enforced at runtime. Product("Keyboard", "expensive") would be accepted; a type checker like mypy would flag it.

Mutable defaults and field()#

Just like function defaults, a mutable default would be shared between instances, so dataclasses refuse it:

Python
from dataclasses import dataclass


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

Use field(default_factory=...), which calls the factory for every new instance:

Python
from dataclasses import dataclass, field
from datetime import datetime


@dataclass
class Order:
    customer: str
    items: list[str] = field(default_factory=list)
    created: datetime = field(default_factory=datetime.now, repr=False, compare=False)
    internal_note: str = field(default="", compare=False)


a = Order("Ada")
b = Order("Grace")
a.items.append("Laptop")
print(a)
print(b)
print(Order("Ada", ["Laptop"], internal_note="vip") == Order("Ada", ["Laptop"]))
Output
Order(customer='Ada', items=['Laptop'], internal_note='')
Order(customer='Grace', items=[], internal_note='')
True

field() options let you hide a field from repr (repr=False), ignore it in comparisons (compare=False), or exclude it from __init__ (init=False). Here created is hidden and ignored in comparisons — otherwise two orders made a microsecond apart would never be equal.

Validation and derived fields: __post_init__#

The generated __init__ calls __post_init__ (if you define it) after setting the fields — the place for validation and computed attributes:

Python
from dataclasses import dataclass, field


@dataclass
class Rectangle:
    width: float
    height: float
    area: float = field(init=False)

    def __post_init__(self):
        if self.width <= 0 or self.height <= 0:
            raise ValueError("sides must be positive")
        self.area = self.width * self.height


print(Rectangle(3, 4))
try:
    Rectangle(-1, 4)
except ValueError as e:
    print("ValueError:", e)
Output
Rectangle(width=3, height=4, area=12)
ValueError: sides must be positive

Immutable dataclasses: frozen=True#

Freezing prevents changes after creation, which makes objects safer to share and lets them be dict keys and set members:

Python
from dataclasses import dataclass, replace, FrozenInstanceError


@dataclass(frozen=True)
class Point:
    x: float
    y: float


p = Point(1, 2)
try:
    p.x = 10
except FrozenInstanceError as e:
    print("FrozenInstanceError:", e)

moved = replace(p, x=10)          # create a modified copy
print(p, moved)
print({p: "start", moved: "end"}[Point(1, 2)])
Output
FrozenInstanceError: cannot assign to field 'x'
Point(x=1, y=2) Point(x=10, y=2)
start

Ordering, slots and keyword-only fields#

Python
from dataclasses import dataclass


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


print(sorted([Version(3, 12, 1), Version(3, 9), Version(3, 13)]))


@dataclass(slots=True, kw_only=True)
class Config:
    host: str = "localhost"
    port: int = 8000
    debug: bool = False


print(Config(port=5000, debug=True))
try:
    Config("example.com")
except TypeError as e:
    print("TypeError:", e)
Output
[Version(major=3, minor=9, patch=0), Version(major=3, minor=12, patch=1), Version(major=3, minor=13, patch=0)]
Config(host='localhost', port=5000, debug=True)
TypeError: Config.__init__() takes 1 positional argument but 2 were given
  • order=True generates <, <=, >, >=, comparing fields in order like tuples.
  • slots=True (3.10+) uses __slots__: less memory, faster attribute access, and typos like cfg.prot = 1 raise AttributeError.
  • kw_only=True (3.10+) forces keyword arguments — great for config objects with many fields.

Converting to dicts and tuples#

Python
from dataclasses import dataclass, asdict, astuple
import json


@dataclass
class User:
    id: int
    name: str
    tags: list[str]


u = User(1, "Ada", ["admin"])
print(asdict(u))
print(astuple(u))
print(json.dumps(asdict(u)))
Output
{'id': 1, 'name': 'Ada', 'tags': ['admin']}
(1, 'Ada', ['admin'])
{"id": 1, "name": "Ada", "tags": ["admin"]}

asdict works recursively on nested dataclasses, which makes JSON serialisation easy.

Inheritance#

Dataclasses can inherit from each other; fields are combined in order from base to subclass:

Python
from dataclasses import dataclass


@dataclass
class Animal:
    name: str
    legs: int = 4


@dataclass
class Bird(Animal):
    can_fly: bool = True


print(Bird("Parrot", 2))
Output
Bird(name='Parrot', legs=2, can_fly=True)

Dataclass, NamedTuple, dict — or Pydantic?#

ToolBest for
dictloosely structured data, JSON you just pass through
NamedTuplesmall immutable records that should also behave like tuples
@dataclassthe default choice for your own data-holding classes
Pydantic / attrs (third-party)runtime validation and parsing of untrusted input (e.g. API payloads)

FastAPI, which you'll meet in the web lesson, uses Pydantic models — they look very similar to dataclasses but validate and convert types at runtime.

Worked example: an inventory#

Python
from dataclasses import dataclass, field


@dataclass(order=True)
class Item:
    sort_index: float = field(init=False, repr=False)
    name: str
    price: float
    qty: int = 0

    def __post_init__(self):
        self.sort_index = self.price * self.qty     # sort by stock value

    @property
    def value(self):
        return self.price * self.qty


@dataclass
class Inventory:
    items: list[Item] = field(default_factory=list)

    def add(self, *items: Item) -> None:
        self.items.extend(items)

    def report(self) -> str:
        lines = [f"{i.name:<10}{i.qty:>4} × {i.price:>8,.2f} = {i.value:>10,.2f}"
                 for i in sorted(self.items, reverse=True)]
        lines.append(f"{'TOTAL':<28}{sum(i.value for i in self.items):>10,.2f}")
        return "\n".join(lines)


inv = Inventory()
inv.add(Item("Mouse", 799, 10), Item("Laptop", 55000, 2), Item("Cable", 349, 25))
print(inv.report())
Output
Laptop       2 × 55,000.00 = 110,000.00
Cable       25 ×   349.00 =   8,725.00
Mouse       10 ×   799.00 =   7,990.00
TOTAL                       126,715.00

Common mistakes#

  • Forgetting the annotation: name = "x" without : str is a plain class attribute, not a field.
  • Mutable defaults — use field(default_factory=list).
  • Non-default field after a default one → TypeError: non-default argument 'b' follows default argument .... Reorder fields or use kw_only=True.
  • Expecting runtime type checking — dataclasses don't validate types; use __post_init__ or Pydantic.

What's next#

That wraps up object-oriented Python. Next we'll look at how for loops really work under the hood — iterators and generators.

Check your understanding

Quick quiz

0/3 answered
  1. 1.Which methods does @dataclass generate by default?

  2. 2.How do you give a dataclass field a default empty list?

  3. 3.What does @dataclass(frozen=True) do?

Finished reading?

Mark this lesson complete to track your progress.