Dataclasses
1 · The lesson
readA 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:
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:
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:
@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:
@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=...):
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:
@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
setor use them asdictkeys. Regular@dataclassinstances are unhashable because they override__eq__. - Safe to share — pass a frozen instance through ten layers of code, you know nobody mutated it.
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:
@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):
@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):
@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__:
@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:
| Argument | Effect |
|---|---|
default=... | Simple default value |
default_factory=... | Zero-arg callable run per instance (for mutables) |
init=False | Don't include in __init__ parameters |
repr=False | Don't include in __repr__ output |
compare=False | Don't include in __eq__ or ordering |
hash=False | Don't include in __hash__ |
@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:
@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
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:
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:
| Tool | Mutable? | Hashable? | Type-checked? | Best for |
|---|---|---|---|---|
@dataclass | Yes | No (by default) | Annotations only | Most cases — modelled data with optional methods |
@dataclass(frozen=True) | No | Yes | Annotations only | Value objects, dict keys, hashable records |
NamedTuple | No | Yes | Annotations only | Tuple-like records, indexable + unpackable |
TypedDict | Yes (it's a dict) | No | Static-check only | Wire formats — JSON shapes you don't want to convert |
Plain class | Yes | Identity only | Manual | When you need real behaviour, not just data |
Plain dict | Yes | No | None | Throwaway 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.
@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.
@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:
@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:
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
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
@dataclassgenerates__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=Truemakes a dataclass immutable and hashable. Caveat: shallow — nested mutables stay mutable.order=Trueauto-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-attributeinit,repr,compare,hashbehaviour.slots=Truefor memory savings and tighter attribute discipline on hot-path classes.asdict/astuplefor clean conversion — pairs nicely withjson.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.inShort exercises that run in your browser and tell you what your code actually did, not just whether a test passed.