Skip to main content
Python · lesson 18 of 34
Python intermediate Lesson 18 of 34

Python Dataclasses

Use the @dataclass decorator to generate __init__, __repr__, and __eq__ automatically, plus frozen instances and post-init validation.

By SkillByExample · Updated

A dataclass is an ordinary class where you declare fields once, as type-annotated class attributes, and @dataclass generates the boilerplate methods for you. Output below is from Python 3.13.

Generated __init__, __repr__, __eq__

Compare a hand-written class against the same thing built with @dataclass.

from dataclasses import dataclass

class PointManual:                   # hand-written: __init__, __repr__, __eq__ all by hand
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"PointManual(x={self.x!r}, y={self.y!r})"

    def __eq__(self, other):
        if not isinstance(other, PointManual):
            return NotImplemented
        return (self.x, self.y) == (other.x, other.y)

@dataclass
class Point:                         # @dataclass generates __init__, __repr__, __eq__ for you
    x: int
    y: int

p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1)
print(p1 == p2)

m1 = PointManual(1, 2)
m2 = PointManual(1, 2)
print(m1)
print(m1 == m2)
$ python points.py
Point(x=1, y=2)
True
PointManual(x=1, y=2)
True

Both produce identical behavior — a readable repr and field-by-field equality — but Point needed four lines of field declarations while PointManual needed the same logic spelled out by hand across three methods.

Mutable Defaults, Frozen Instances, and Validation

First, what happens if you give a field a plain list as its default.

from dataclasses import dataclass

try:
    @dataclass
    class Cart:
        owner: str
        items: list = []        # looks harmless, but is rejected
except ValueError as e:
    print(f"ValueError: {e}")
$ python shared_default.py
ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory

The error fires when the class is defined, not when an instance is created. A plain = [] default would be one list shared by every Cart, the same trap as mutable default arguments, so @dataclass refuses list, dict and set defaults outright and points you at the fix.

field(default_factory=...) is that fix, frozen=True makes instances immutable, and __post_init__ runs right after construction for validation.

from dataclasses import dataclass, field

@dataclass(frozen=True)              # frozen - instances become immutable after creation
class Order:
    id: int
    items: list = field(default_factory=list)   # mutable defaults need default_factory
    total: float = 0.0

    def __post_init__(self):
        # runs right after __init__ - a natural place to validate invariants
        if self.total < 0:
            raise ValueError("total cannot be negative")

o = Order(id=1, items=["book", "pen"])
print(o)

try:
    o.total = 50
except Exception as e:                # frozen dataclasses raise a dataclasses.FrozenInstanceError
    print(f"{type(e).__name__}: {e}")

try:
    Order(id=2, total=-10)
except ValueError as e:
    print(f"ValueError: {e}")
$ python orders.py
Order(id=1, items=['book', 'pen'], total=0.0)
FrozenInstanceError: cannot assign to field 'total'
ValueError: total cannot be negative

Each Order gets its own fresh items list because default_factory=list calls list() per instance, which is exactly what the ValueError above was asking for. frozen=True blocks the later assignment to total entirely, and __post_init__ catches the invalid -10 before construction ever completes.

Practice

1. Create a Person dataclass with name, age, and an email: str = "" field, and confirm @dataclass includes email in both repr and equality automatically.

Solution
from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int
    email: str = ""

p1 = Person("Arjun", 30)
p2 = Person("Arjun", 30, "arjun@example.com")
print(p1)
print(p2)
print(p1 == p2)
$ python person.py
Person(name='Arjun', age=30, email='')
Person(name='Arjun', age=30, email='arjun@example.com')
False

Every annotated field takes part in the generated __repr__ and __eq__, defaulted ones included, so the two people differ only by email and still compare unequal.

2. Remove frozen=True and show that total can now be reassigned to an invalid value. Why is that a liability?

Solution
from dataclasses import dataclass

@dataclass
class MutableOrder:
    id: int
    total: float = 0.0

    def __post_init__(self):
        if self.total < 0:
            raise ValueError("total cannot be negative")

o = MutableOrder(id=1, total=100)
o.total = -999    # __post_init__ does not run again on assignment
print(o)
$ python mutable_order.py
MutableOrder(id=1, total=-999)

__post_init__ runs once, during construction. Assigning a field afterward bypasses validation entirely, which is why frozen=True plus validation is the safer combination for value objects.

3. Use dataclasses.asdict() to convert an Order into a plain dict, and dataclasses.astuple() to convert it into a tuple.

Solution
from dataclasses import dataclass, field, asdict, astuple

@dataclass(frozen=True)
class Order:
    id: int
    items: list = field(default_factory=list)
    total: float = 0.0

o = Order(id=1, items=["book"], total=25.0)
print(asdict(o))
print(astuple(o))
$ python order_convert.py
{'id': 1, 'items': ['book'], 'total': 25.0}
(1, ['book'], 25.0)

Both helpers walk the fields in declaration order and copy nested containers recursively, which makes asdict() the usual first step before json.dumps().

4. Add order=True to a new Money dataclass with a single amount: float field, then sort a list of Money instances directly.

Solution
from dataclasses import dataclass

@dataclass(order=True)
class Money:
    amount: float

amounts = [Money(50), Money(10), Money(30)]
print(sorted(amounts))
print(Money(10) < Money(30))
$ python money.py
[Money(amount=10), Money(amount=30), Money(amount=50)]
True

order=True generates __lt__, __le__, __gt__ and __ge__, comparing fields as a tuple in declaration order, so sorted() works without a custom key function.

Frequently Asked Questions

What does the @dataclass decorator do?
It generates __init__, __repr__, and __eq__ automatically from a class's type-annotated fields, eliminating the boilerplate you'd otherwise write by hand for a class that mainly holds data.
Why can't I use a mutable default like a list directly on a dataclass field?
A plain default like items: list = [] would be shared by every instance, the same trap as mutable default arguments, so @dataclass refuses it: the class definition itself raises ValueError. Use field(default_factory=list) instead, which calls list() fresh for each new instance.
What does frozen=True do?
It makes instances immutable after __init__ runs — assigning to any field afterward raises dataclasses.FrozenInstanceError. Useful for value objects that shouldn't change once created.
When does __post_init__ run?
Immediately after the generated __init__ finishes assigning all fields. It's the standard place to add validation or compute a derived field, since by the time it runs every field already has its initial value.