PythonMastery
intermediate 18 min read · lesson 3 of 13 in Python Intermediate

Dataclasses

1 · The lesson

read

A class that exists mainly to hold data — a point, an order, a config — needs __init__, __repr__, and __eq__ by hand every time. That's ten lines of boilerplate for what could be three. @dataclass generates all of it from the annotated fields, leaving you to focus on the actual behaviour.

This lesson covers the decorator, the field defaults trap, frozen and ordered dataclasses, __post_init__ validation, and when a dataclass is the wrong tool.


1. The Boilerplate Problem

Writing a plain class to hold three fields is mostly typing:

python
class Point:
    def __init__(self, x, y, z=0):
        self.x = x
        self.y = y
        self.z = z

    def __repr__(self):
        return f"Point(x={self.x}, y={self.y}, z={self.z})"

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

Twelve lines, zero behaviour. The dataclass version:

python
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float
    z: float = 0

p = Point(1, 2)
print(p)                                # Point(x=1, y=2, z=0)
print(p == Point(1, 2))                 # True

@dataclass reads the annotated class-body attributes and generates __init__, __repr__, and __eq__. The type annotations are required — that's how Python knows which attributes count as fields.


2. Field Defaults and default_factory

Simple defaults sit next to the annotation:

python
@dataclass
class Config:
    host: str = "localhost"
    port: int = 5432
    ssl: bool = False
+ setup added so this can run · defines dataclass
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

dataclass = _AutoMock('dataclass')

Mutable defaults are forbidden. Try the obvious thing and Python refuses outright:

python
@dataclass
class Cart:
    items: list = []                    # ValueError: mutable default <class 'list'> for field items
                                        # is not allowed: use default_factory
+ setup added so this can run · defines dataclass
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

dataclass = _AutoMock('dataclass')

This is the same trap as the mutable-default-argument problem from functions — one list would be shared by every Cart() instance. The fix is field(default_factory=...):

python
from dataclasses import dataclass, field

@dataclass
class Cart:
    items: list = field(default_factory=list)
    tags: set = field(default_factory=set)
    metadata: dict = field(default_factory=dict)

a, b = Cart(), Cart()
a.items.append("apple")
print(a.items, b.items)                 # ['apple'] []   — separate lists

default_factory is a zero-arg callable that runs once per instance. Use list, dict, set directly, or lambda: ... for custom values.


3. frozen=True — Immutable Dataclasses

Pass frozen=True and the generated class refuses attribute assignment after construction:

python
@dataclass(frozen=True)
class Money:
    amount: int
    currency: str

m = Money(100, "USD")
print(m)                                # Money(amount=100, currency='USD')
# m.amount = 200                        # FrozenInstanceError: cannot assign to field 'amount'
+ setup added so this can run · defines dataclass
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def dataclass(*_a, **_kw):
    print('-> dataclass() called')
    return _AutoMock('dataclass()')

Two big wins:

  • Hashable by default — you can put frozen dataclasses into a set or use them as dict keys. Regular @dataclass instances are unhashable because they override __eq__.
  • Safe to share — pass a frozen instance through ten layers of code, you know nobody mutated it.
python
prices = {Money(100, "USD"): "premium", Money(50, "USD"): "basic"}
print(prices[Money(100, "USD")])        # premium
+ setup added so this can run · defines Money
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def Money(*_a, **_kw):
    print('-> Money() called')
    return _AutoMock('Money()')

Use frozen for value objects — anything where identity is just the data (money, dates, points, coordinates).

Caveat: frozen=True is shallow. The dataclass refuses to rebind its own attributes, but nested mutables are still mutable:

python
@dataclass(frozen=True)
class Bag:
    items: list = field(default_factory=list)

b = Bag()
b.items.append("apple")                 # works — the list itself isn't frozen
print(b.items)                          # ['apple']
+ setup added so this can run · defines dataclass, field
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def dataclass(*_a, **_kw):
    print('-> dataclass() called')
    return _AutoMock('dataclass()')
def field(*_a, **_kw):
    print('-> field() called')
    return _AutoMock('field()')

If you need deep immutability, use tuples or frozen dataclasses all the way down.


4. order=True — Auto-Generated Ordering

order=True generates __lt__, __le__, __gt__, __ge__, comparing fields in declaration order (as a tuple):

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

versions = [Version(1, 2, 0), Version(0, 9, 5), Version(1, 1, 9)]
print(sorted(versions))
# [Version(0, 9, 5), Version(1, 1, 9), Version(1, 2, 0)]
+ setup added so this can run · defines dataclass
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def dataclass(*_a, **_kw):
    print('-> dataclass() called')
    return _AutoMock('dataclass()')

Sometimes you want to sort by one field and ignore others — exclude them with field(compare=False):

python
@dataclass(order=True)
class Task:
    priority: int
    description: str = field(compare=False)         # name doesn't affect sort order

tasks = [Task(3, "review PR"), Task(1, "fix bug"), Task(2, "write docs")]
print(sorted(tasks))                                # sorted by priority only
+ setup added so this can run · defines dataclass, field
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def dataclass(*_a, **_kw):
    print('-> dataclass() called')
    return _AutoMock('dataclass()')
def field(*_a, **_kw):
    print('-> field() called')
    return _AutoMock('field()')

5. __post_init__ — Validation and Derived Fields

Sometimes you need code to run after the generated __init__ finishes — for validation, computing derived fields, or normalising input. Define __post_init__:

python
@dataclass
class Rectangle:
    width: float
    height: float
    area: float = field(init=False)             # excluded from __init__ params

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

r = Rectangle(3, 4)
print(r.area)                                   # 12
# Rectangle(-1, 4)                              # ValueError
+ setup added so this can run · defines dataclass, field
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

dataclass = _AutoMock('dataclass')
def field(*_a, **_kw):
    print('-> field() called')
    return _AutoMock('field()')

init=False keeps area out of the generated __init__ signature — you don't pass it; __post_init__ computes it.


6. Fine-Grained field() Control

field() accepts several knobs for individual attributes:

ArgumentEffect
default=...Simple default value
default_factory=...Zero-arg callable run per instance (for mutables)
init=FalseDon't include in __init__ parameters
repr=FalseDon't include in __repr__ output
compare=FalseDon't include in __eq__ or ordering
hash=FalseDon't include in __hash__
python
@dataclass
class User:
    username: str
    password_hash: str = field(repr=False)              # hide from print(user)
    created_at: float = field(default_factory=time.time, compare=False)
    cache: dict = field(default_factory=dict, repr=False, compare=False, init=False)

u = User("surya", "$argon2id$...")
print(u)                                                # User(username='surya')
+ setup added so this can run · defines dataclass, field, time
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

dataclass = _AutoMock('dataclass')
def field(*_a, **_kw):
    print('-> field() called')
    return _AutoMock('field()')
time = _AutoMock('time')

repr=False on secrets is a small but real security win — it stops the value showing up in logs the next time someone prints the object.


7. slots=True — Memory and Attribute Discipline (3.10+)

By default, Python classes store attributes in a per-instance __dict__, which is flexible but uses memory. slots=True swaps that for a fixed __slots__ declaration:

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

p = Point(1, 2)
# p.z = 3                                       # AttributeError: 'Point' object has no attribute 'z'
+ setup added so this can run · defines dataclass
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def dataclass(*_a, **_kw):
    print('-> dataclass() called')
    return _AutoMock('dataclass()')

Two effects:

  • Lower memory — meaningful when you create millions of instances. Often 30-50% less RAM per object.
  • No dynamic attributes — typos and stray writes get caught at assignment time, not later.

The trade-off: harder to subclass, doesn't play with some pickling or mixin patterns. Reach for it on hot-path data classes, skip it elsewhere.


8. asdict and astuple — Conversion Helpers

python
from dataclasses import asdict, astuple

@dataclass
class Point:
    x: int
    y: int

p = Point(3, 4)
print(asdict(p))                                # {'x': 3, 'y': 4}
print(astuple(p))                               # (3, 4)
+ setup added so this can run · defines dataclass
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

dataclass = _AutoMock('dataclass')

Both recurse — a dataclass containing dataclasses serialises to a nested dict/tuple. asdict is one of the cleanest ways to feed a dataclass into json.dumps:

python
import json
print(json.dumps(asdict(p)))                    # {"x": 3, "y": 4}
+ setup added so this can run · defines asdict, p
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def asdict(*_a, **_kw):
    print('-> asdict() called')
    return _AutoMock('asdict()')
p = _AutoMock('p')

9. When to Use What

Python has several "bag of fields" tools. Quick comparison:

ToolMutable?Hashable?Type-checked?Best for
@dataclassYesNo (by default)Annotations onlyMost cases — modelled data with optional methods
@dataclass(frozen=True)NoYesAnnotations onlyValue objects, dict keys, hashable records
NamedTupleNoYesAnnotations onlyTuple-like records, indexable + unpackable
TypedDictYes (it's a dict)NoStatic-check onlyWire formats — JSON shapes you don't want to convert
Plain classYesIdentity onlyManualWhen you need real behaviour, not just data
Plain dictYesNoNoneThrowaway data, sets of unknown keys

Rule of thumb: default to @dataclass. Promote to frozen=True when you want hashability or immutability. Drop to a plain dict when the keys are dynamic. Promote to a full class when behaviour outweighs data.


Common Mistakes

1. default=[] instead of default_factory=list — Python 3.11+ raises immediately, but older versions silently shared the list across every instance. Always field(default_factory=list) for mutables.

2. Expecting frozen=True to deep-freeze.

python
@dataclass(frozen=True)
class Box:
    items: list = field(default_factory=list)

b = Box()
b.items.append("a")                             # still works — the list isn't frozen
# b.items = []                                  # FrozenInstanceError — rebinding is what's blocked
+ setup added so this can run · defines dataclass, field
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def dataclass(*_a, **_kw):
    print('-> dataclass() called')
    return _AutoMock('dataclass()')
def field(*_a, **_kw):
    print('-> field() called')
    return _AutoMock('field()')

frozen blocks attribute rebinding. The objects those attributes refer to are unaffected. Use tuples for nested immutability.

3. Custom __eq__ without eq=False.

python
@dataclass
class Account:
    id: str
    balance: int

    def __eq__(self, other):                    # ⚠️ silently ignored
        return isinstance(other, Account) and self.id == other.id
+ setup added so this can run · defines dataclass
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

dataclass = _AutoMock('dataclass')

@dataclass generates __eq__ by default — your hand-written one gets overwritten. Pass eq=False to suppress generation:

python
@dataclass(eq=False)
class Account:
    id: str
    balance: int

    def __eq__(self, other):
        return isinstance(other, Account) and self.id == other.id

    def __hash__(self):
        return hash(self.id)
+ setup added so this can run · defines dataclass
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def dataclass(*_a, **_kw):
    print('-> dataclass() called')
    return _AutoMock('dataclass()')

4. Comparing dataclasses across types — always False. Generated __eq__ checks type(self) is type(other) first. A Point and a Pixel with the same (x, y) are not equal. Often what you want, but worth knowing.

5. Inheriting from a dataclass with defaults. Once a parent field has a default, every child field must also have one — same rule as function parameters with defaults. Easy to hit when extending a parent that has even one default.


🎯 Your Turn — Building an Order

Build an Order dataclass that models a shopping order with line items, validation, and a nested frozen Address. Requirements:

1. A frozen Address dataclass with street, city, postcode — hashable so it can go in a set.
2. A LineItem dataclass with name, quantity (int), unit_price (float).
3. An Order dataclass with order_id, shipping_address (an Address), and items (a list of LineItem, defaulting to empty).
4. __post_init__ on Order validates that every line item has quantity > 0.
5. A total @property on Order returning the sum of quantity * unit_price across all items.
6. An add(item) method that appends a LineItem, re-validating quantity.

Skeleton:

python
from dataclasses import dataclass, field
from typing import List


@dataclass(frozen=True)
class Address:
    # TODO 1: street, city, postcode — all str
    ...


@dataclass
class LineItem:
    # TODO 2
    ...


@dataclass
class Order:
    order_id: str
    shipping_address: Address
    items: List[LineItem] = field(default_factory=list)

    def __post_init__(self):
        # TODO 3: raise ValueError if any item has quantity <= 0
        ...

    @property
    def total(self):
        # TODO 4
        ...

    def add(self, item):
        # TODO 5: validate, then append
        ...


addr = Address("221B Baker St", "London", "NW1 6XE")
order = Order("ORD-001", addr, [
    LineItem("widget", 3, 9.99),
    LineItem("gadget", 1, 49.50),
])
print(order.total)                                  # 79.47
order.add(LineItem("gizmo", 2, 5.00))
print(order.total)                                  # 89.47

# Address is hashable — usable as a dict key
warehouses = {addr: "London hub"}
print(warehouses[Address("221B Baker St", "London", "NW1 6XE")])
Hint 1 — Frozen + hashable @dataclass(frozen=True) on Address gives you both immutability and a working __hash__ so two addresses with the same fields hash identically.
Hint 2 — Validating in __post_init__ Loop over self.items and raise ValueError if any item.quantity <= 0. Use the same check inside add before appending, or factor it out into a small helper method.
Show full solution
python
from dataclasses import dataclass, field
from typing import List


@dataclass(frozen=True)
class Address:
    street: str
    city: str
    postcode: str


@dataclass
class LineItem:
    name: str
    quantity: int
    unit_price: float

    @property
    def subtotal(self):
        return self.quantity * self.unit_price


@dataclass
class Order:
    order_id: str
    shipping_address: Address
    items: List[LineItem] = field(default_factory=list)

    def __post_init__(self):
        for item in self.items:
            self._validate(item)

    @staticmethod
    def _validate(item):
        if item.quantity <= 0:
            raise ValueError(f"quantity must be positive, got {item.quantity} for {item.name!r}")

    @property
    def total(self):
        return sum(item.subtotal for item in self.items)

    def add(self, item):
        self._validate(item)
        self.items.append(item)


addr = Address("221B Baker St", "London", "NW1 6XE")
order = Order("ORD-001", addr, [
    LineItem("widget", 3, 9.99),
    LineItem("gadget", 1, 49.50),
])
print(f"initial total: {order.total:.2f}")

order.add(LineItem("gizmo", 2, 5.00))
print(f"after add:     {order.total:.2f}")

# Try a bad one
try:
    order.add(LineItem("broken", 0, 100))
except ValueError as e:
    print(f"refused: {e}")

# Address as a dict key — works because frozen=True implies hashable
warehouses = {addr: "London hub"}
print(warehouses[Address("221B Baker St", "London", "NW1 6XE")])

Three dataclasses, ~30 lines, with validation, a derived total, hashable nested objects, and clean tracebacks on bad input. Reaching for plain classes here would be roughly three times the code.


What You Learned

  • @dataclass generates __init__, __repr__, and __eq__ from annotated fields. Type hints are mandatory.
  • field(default_factory=...) for mutable defaults — same root cause as the mutable-default-argument bug.
  • frozen=True makes a dataclass immutable and hashable. Caveat: shallow — nested mutables stay mutable.
  • order=True auto-generates comparison operators. field(compare=False) to exclude individual fields.
  • __post_init__ runs validation or computes derived fields after the generated __init__.
  • field() controls per-attribute init, repr, compare, hash behaviour.
  • slots=True for memory savings and tighter attribute discipline on hot-path classes.
  • asdict / astuple for clean conversion — pairs nicely with json.dumps.

Next: dive into the Advanced path for decorators, generators, and the protocol-style toolkit that completes Python's class system.

Practice this

on practicepython.in

Short exercises that run in your browser and tell you what your code actually did, not just whether a test passed.