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__, written by hand every time. That's about ten lines of repeated code for what could be three. @dataclass generates all of it from the annotated fields, so you only write the behaviour that is specific to your class.

This lesson covers the decorator, the common mistake with field defaults, 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)

That is twelve lines, and none of them is real 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. They are how Python knows which attributes are 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 · re-runs 1 earlier block on this page first
# 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')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    from dataclasses import dataclass
except Exception:
    pass
_pm_earlier = [
    'from dataclasses import dataclass\n\n@dataclass\nclass Point:\n    x: float\n    y: float\n    z: float = 0\n\np = Point(1, 2)\nprint(p)                                # Point(x=1, y=2, z=0)\nprint(p == Point(1, 2))                 # True',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

Mutable defaults are not allowed. If you try the obvious thing, Python raises an error:

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 · re-runs 1 earlier block on this page first
# 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')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    from dataclasses import dataclass
except Exception:
    pass
_pm_earlier = [
    'from dataclasses import dataclass\n\n@dataclass\nclass Point:\n    x: float\n    y: float\n    z: float = 0\n\np = Point(1, 2)\nprint(p)                                # Point(x=1, y=2, z=0)\nprint(p == Point(1, 2))                 # True',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

This is the same problem as the mutable default argument 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'] []   (each Cart has its own list)

default_factory takes a function with no arguments, and calls it once for each new instance. Pass list, dict or set directly, or lambda: ... for custom values.


3. frozen=True: Immutable Dataclasses

With frozen=True, you can't assign to an attribute after the object is created:

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 · re-runs 2 earlier blocks on this page first
# 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()')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    from dataclasses import dataclass
    from dataclasses import dataclass, field
except Exception:
    pass
_pm_earlier = [
    'from dataclasses import dataclass\n\n@dataclass\nclass Point:\n    x: float\n    y: float\n    z: float = 0\n\np = Point(1, 2)\nprint(p)                                # Point(x=1, y=2, z=0)\nprint(p == Point(1, 2))                 # True',
    'from dataclasses import dataclass, field\n\n@dataclass\nclass Cart:\n    items: list = field(default_factory=list)\n    tags: set = field(default_factory=set)\n    metadata: dict = field(default_factory=dict)\n\na, b = Cart(), Cart()\na.items.append("apple")\nprint(a.items, b.items)                 # [\'apple\'] []   (each Cart has its own list)',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

This gives you two benefits:

  • 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: if you pass a frozen instance through ten layers of code, you know that no code changed it.
python
prices = {Money(100, "USD"): "premium", Money(50, "USD"): "basic"}
print(prices[Money(100, "USD")])        # premium
+ setup added so this can run · re-runs 3 earlier blocks on this page first
# 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()')
def dataclass(*_a, **_kw):
    print('-> dataclass() called')
    return _AutoMock('dataclass()')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    from dataclasses import dataclass
    from dataclasses import dataclass, field
except Exception:
    pass
_pm_earlier = [
    'from dataclasses import dataclass\n\n@dataclass\nclass Point:\n    x: float\n    y: float\n    z: float = 0\n\np = Point(1, 2)\nprint(p)                                # Point(x=1, y=2, z=0)\nprint(p == Point(1, 2))                 # True',
    'from dataclasses import dataclass, field\n\n@dataclass\nclass Cart:\n    items: list = field(default_factory=list)\n    tags: set = field(default_factory=set)\n    metadata: dict = field(default_factory=dict)\n\na, b = Cart(), Cart()\na.items.append("apple")\nprint(a.items, b.items)                 # [\'apple\'] []   (each Cart has its own list)',
    '@dataclass(frozen=True)\nclass Money:\n    amount: int\n    currency: str\n\nm = Money(100, "USD")\nprint(m)                                # Money(amount=100, currency=\'USD\')\n# m.amount = 200                        # FrozenInstanceError: cannot assign to field \'amount\'',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

Use frozen dataclasses for value objects: objects that are defined only by their data (money, dates, points, coordinates).

Note: frozen=True is shallow. You can't rebind the dataclass's own attributes, but mutable objects inside it can still change:

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

b = Bag()
b.items.append("apple")                 # works, because the list itself is not frozen
print(b.items)                          # ['apple']
+ setup added so this can run · re-runs 2 earlier blocks on this page first
# 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()')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    from dataclasses import dataclass
    from dataclasses import dataclass, field
except Exception:
    pass
_pm_earlier = [
    'from dataclasses import dataclass\n\n@dataclass\nclass Point:\n    x: float\n    y: float\n    z: float = 0\n\np = Point(1, 2)\nprint(p)                                # Point(x=1, y=2, z=0)\nprint(p == Point(1, 2))                 # True',
    'from dataclasses import dataclass, field\n\n@dataclass\nclass Cart:\n    items: list = field(default_factory=list)\n    tags: set = field(default_factory=set)\n    metadata: dict = field(default_factory=dict)\n\na, b = Cart(), Cart()\na.items.append("apple")\nprint(a.items, b.items)                 # [\'apple\'] []   (each Cart has its own list)',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

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__ and __ge__. They compare the fields in the order you declared them, the same way tuples are compared:

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(major=0, minor=9, patch=5), Version(major=1, minor=1, patch=9), Version(major=1, minor=2, patch=0)]
+ setup added so this can run · re-runs 2 earlier blocks on this page first
# 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()')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    from dataclasses import dataclass
    from dataclasses import dataclass, field
except Exception:
    pass
_pm_earlier = [
    'from dataclasses import dataclass\n\n@dataclass\nclass Point:\n    x: float\n    y: float\n    z: float = 0\n\np = Point(1, 2)\nprint(p)                                # Point(x=1, y=2, z=0)\nprint(p == Point(1, 2))                 # True',
    'from dataclasses import dataclass, field\n\n@dataclass\nclass Cart:\n    items: list = field(default_factory=list)\n    tags: set = field(default_factory=set)\n    metadata: dict = field(default_factory=dict)\n\na, b = Cart(), Cart()\na.items.append("apple")\nprint(a.items, b.items)                 # [\'apple\'] []   (each Cart has its own list)',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

Sometimes you want to sort by one field and ignore the others. Exclude a field from comparison with field(compare=False):

python
@dataclass(order=True)
class Task:
    priority: int
    description: str = field(compare=False)         # description is ignored when sorting

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 · re-runs 2 earlier blocks on this page first
# 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()')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    from dataclasses import dataclass
    from dataclasses import dataclass, field
except Exception:
    pass
_pm_earlier = [
    'from dataclasses import dataclass\n\n@dataclass\nclass Point:\n    x: float\n    y: float\n    z: float = 0\n\np = Point(1, 2)\nprint(p)                                # Point(x=1, y=2, z=0)\nprint(p == Point(1, 2))                 # True',
    'from dataclasses import dataclass, field\n\n@dataclass\nclass Cart:\n    items: list = field(default_factory=list)\n    tags: set = field(default_factory=set)\n    metadata: dict = field(default_factory=dict)\n\na, b = Cart(), Cart()\na.items.append("apple")\nprint(a.items, b.items)                 # [\'apple\'] []   (each Cart has its own list)',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

5. __post_init__: Validation and Derived Fields

Sometimes you need code to run after the generated __init__ finishes: to validate input, compute derived fields, or normalise values. Define __post_init__:

python
@dataclass
class Rectangle:
    width: float
    height: float
    area: float = field(init=False)             # not a parameter of __init__

    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 · re-runs 2 earlier blocks on this page first
# 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()')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    from dataclasses import dataclass
    from dataclasses import dataclass, field
except Exception:
    pass
_pm_earlier = [
    'from dataclasses import dataclass\n\n@dataclass\nclass Point:\n    x: float\n    y: float\n    z: float = 0\n\np = Point(1, 2)\nprint(p)                                # Point(x=1, y=2, z=0)\nprint(p == Point(1, 2))                 # True',
    'from dataclasses import dataclass, field\n\n@dataclass\nclass Cart:\n    items: list = field(default_factory=list)\n    tags: set = field(default_factory=set)\n    metadata: dict = field(default_factory=dict)\n\na, b = Cart(), Cart()\na.items.append("apple")\nprint(a.items, b.items)                 # [\'apple\'] []   (each Cart has its own list)',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

init=False keeps area out of the generated __init__ parameters. You don't pass it, because __post_init__ computes it.


6. Fine-Grained field() Control

field() accepts several options 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
import time
from dataclasses import dataclass, field

@dataclass
class User:
    username: str
    password_hash: str = field(repr=False)              # not shown when you print a 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', created_at=<the current time>)

repr=False on secrets is a small security improvement. The value won't appear in logs when someone prints the object.


7. slots=True: Less Memory, Fixed Attributes (3.10+)

By default, Python classes store attributes in a per-instance __dict__, which is flexible but uses memory. slots=True replaces it with 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 · re-runs 3 earlier blocks on this page first
# 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()')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    from dataclasses import dataclass
    from dataclasses import dataclass, field
    import time
except Exception:
    pass
_pm_earlier = [
    'from dataclasses import dataclass\n\n@dataclass\nclass Point:\n    x: float\n    y: float\n    z: float = 0\n\np = Point(1, 2)\nprint(p)                                # Point(x=1, y=2, z=0)\nprint(p == Point(1, 2))                 # True',
    'from dataclasses import dataclass, field\n\n@dataclass\nclass Cart:\n    items: list = field(default_factory=list)\n    tags: set = field(default_factory=set)\n    metadata: dict = field(default_factory=dict)\n\na, b = Cart(), Cart()\na.items.append("apple")\nprint(a.items, b.items)                 # [\'apple\'] []   (each Cart has its own list)',
    'import time\nfrom dataclasses import dataclass, field\n\n@dataclass\nclass User:\n    username: str\n    password_hash: str = field(repr=False)              # not shown when you print a User\n    created_at: float = field(default_factory=time.time, compare=False)\n    cache: dict = field(default_factory=dict, repr=False, compare=False, init=False)\n\nu = User("surya", "$argon2id$...")\nprint(u)                                                # User(username=\'surya\', created_at=<the current time>)',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

Two effects:

  • Lower memory: this matters when you create millions of instances.
  • No new attributes: a typo in an attribute name raises an error when you assign it, not later.

The cost: such classes are harder to subclass, and some pickling and mixin patterns don't work with them. Use slots=True on data classes that you create in very large numbers, and 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 · re-runs 3 earlier blocks on this page first
# 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')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    from dataclasses import dataclass
    from dataclasses import dataclass, field
    import time
except Exception:
    pass
_pm_earlier = [
    'from dataclasses import dataclass\n\n@dataclass\nclass Point:\n    x: float\n    y: float\n    z: float = 0\n\np = Point(1, 2)\nprint(p)                                # Point(x=1, y=2, z=0)\nprint(p == Point(1, 2))                 # True',
    'from dataclasses import dataclass, field\n\n@dataclass\nclass Cart:\n    items: list = field(default_factory=list)\n    tags: set = field(default_factory=set)\n    metadata: dict = field(default_factory=dict)\n\na, b = Cart(), Cart()\na.items.append("apple")\nprint(a.items, b.items)                 # [\'apple\'] []   (each Cart has its own list)',
    'import time\nfrom dataclasses import dataclass, field\n\n@dataclass\nclass User:\n    username: str\n    password_hash: str = field(repr=False)              # not shown when you print a User\n    created_at: float = field(default_factory=time.time, compare=False)\n    cache: dict = field(default_factory=dict, repr=False, compare=False, init=False)\n\nu = User("surya", "$argon2id$...")\nprint(u)                                                # User(username=\'surya\', created_at=<the current time>)',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

Both are recursive: a dataclass that contains other dataclasses becomes a nested dict or tuple. asdict is one of the cleanest ways to pass a dataclass to json.dumps:

python
import json
print(json.dumps(asdict(p)))                    # {"x": 3, "y": 4}
+ setup added so this can run · re-runs 5 earlier blocks on this page first
# 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')
def dataclass(*_a, **_kw):
    print('-> dataclass() called')
    return _AutoMock('dataclass()')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    from dataclasses import dataclass
    from dataclasses import dataclass, field
    import time
    from dataclasses import asdict, astuple
except Exception:
    pass
_pm_earlier = [
    'from dataclasses import dataclass\n\n@dataclass\nclass Point:\n    x: float\n    y: float\n    z: float = 0\n\np = Point(1, 2)\nprint(p)                                # Point(x=1, y=2, z=0)\nprint(p == Point(1, 2))                 # True',
    'from dataclasses import dataclass, field\n\n@dataclass\nclass Cart:\n    items: list = field(default_factory=list)\n    tags: set = field(default_factory=set)\n    metadata: dict = field(default_factory=dict)\n\na, b = Cart(), Cart()\na.items.append("apple")\nprint(a.items, b.items)                 # [\'apple\'] []   (each Cart has its own list)',
    'import time\nfrom dataclasses import dataclass, field\n\n@dataclass\nclass User:\n    username: str\n    password_hash: str = field(repr=False)              # not shown when you print a User\n    created_at: float = field(default_factory=time.time, compare=False)\n    cache: dict = field(default_factory=dict, repr=False, compare=False, init=False)\n\nu = User("surya", "$argon2id$...")\nprint(u)                                                # User(username=\'surya\', created_at=<the current time>)',
    "@dataclass(slots=True)\nclass Point:\n    x: float\n    y: float\n\np = Point(1, 2)\n# p.z = 3                                       # AttributeError: 'Point' object has no attribute 'z'",
    "from dataclasses import asdict, astuple\n\n@dataclass\nclass Point:\n    x: int\n    y: int\n\np = Point(3, 4)\nprint(asdict(p))                                # {'x': 3, 'y': 4}\nprint(astuple(p))                               # (3, 4)",
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

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: start with @dataclass. Switch to frozen=True when you want hashability or immutability. Use a plain dict when the keys are not known in advance. Use a full class when the behaviour matters more than the data.


Common Mistakes

1. default=[] instead of default_factory=list. @dataclass raises ValueError for a list, dict or set default (and, since Python 3.11, for any unhashable default). Always use field(default_factory=list) for mutable defaults.

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, because the list is not frozen
# b.items = []                                  # FrozenInstanceError  (frozen blocks assigning a new value)
+ setup added so this can run · re-runs 3 earlier blocks on this page first
# 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()')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    from dataclasses import dataclass
    from dataclasses import dataclass, field
    import time
except Exception:
    pass
_pm_earlier = [
    'from dataclasses import dataclass\n\n@dataclass\nclass Point:\n    x: float\n    y: float\n    z: float = 0\n\np = Point(1, 2)\nprint(p)                                # Point(x=1, y=2, z=0)\nprint(p == Point(1, 2))                 # True',
    'from dataclasses import dataclass, field\n\n@dataclass\nclass Cart:\n    items: list = field(default_factory=list)\n    tags: set = field(default_factory=set)\n    metadata: dict = field(default_factory=dict)\n\na, b = Cart(), Cart()\na.items.append("apple")\nprint(a.items, b.items)                 # [\'apple\'] []   (each Cart has its own list)',
    'import time\nfrom dataclasses import dataclass, field\n\n@dataclass\nclass User:\n    username: str\n    password_hash: str = field(repr=False)              # not shown when you print a User\n    created_at: float = field(default_factory=time.time, compare=False)\n    cache: dict = field(default_factory=dict, repr=False, compare=False, init=False)\n\nu = User("surya", "$argon2id$...")\nprint(u)                                                # User(username=\'surya\', created_at=<the current time>)',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

frozen blocks attribute rebinding. The objects those attributes refer to can still change. Use tuples for nested immutability.

3. Custom __eq__ without eq=False.

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

    def __eq__(self, other):                    # ⚠️ kept, but now Account can't be hashed
        return isinstance(other, Account) and self.id == other.id
+ setup added so this can run · re-runs 3 earlier blocks on this page first
# 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')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    from dataclasses import dataclass
    from dataclasses import dataclass, field
    import time
except Exception:
    pass
_pm_earlier = [
    'from dataclasses import dataclass\n\n@dataclass\nclass Point:\n    x: float\n    y: float\n    z: float = 0\n\np = Point(1, 2)\nprint(p)                                # Point(x=1, y=2, z=0)\nprint(p == Point(1, 2))                 # True',
    'from dataclasses import dataclass, field\n\n@dataclass\nclass Cart:\n    items: list = field(default_factory=list)\n    tags: set = field(default_factory=set)\n    metadata: dict = field(default_factory=dict)\n\na, b = Cart(), Cart()\na.items.append("apple")\nprint(a.items, b.items)                 # [\'apple\'] []   (each Cart has its own list)',
    'import time\nfrom dataclasses import dataclass, field\n\n@dataclass\nclass User:\n    username: str\n    password_hash: str = field(repr=False)              # not shown when you print a User\n    created_at: float = field(default_factory=time.time, compare=False)\n    cache: dict = field(default_factory=dict, repr=False, compare=False, init=False)\n\nu = User("surya", "$argon2id$...")\nprint(u)                                                # User(username=\'surya\', created_at=<the current time>)',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

@dataclass keeps your hand-written __eq__, but because eq=True is still the default it sets __hash__ to None, so putting an Account in a set or using it as a dict key raises TypeError: unhashable type. Pass eq=False and write __hash__ yourself:

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 · re-runs 3 earlier blocks on this page first
# 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()')

# Earlier blocks on this page that this one builds on, re-run quietly first.
try:
    from dataclasses import dataclass
    from dataclasses import dataclass, field
    import time
except Exception:
    pass
_pm_earlier = [
    'from dataclasses import dataclass\n\n@dataclass\nclass Point:\n    x: float\n    y: float\n    z: float = 0\n\np = Point(1, 2)\nprint(p)                                # Point(x=1, y=2, z=0)\nprint(p == Point(1, 2))                 # True',
    'from dataclasses import dataclass, field\n\n@dataclass\nclass Cart:\n    items: list = field(default_factory=list)\n    tags: set = field(default_factory=set)\n    metadata: dict = field(default_factory=dict)\n\na, b = Cart(), Cart()\na.items.append("apple")\nprint(a.items, b.items)                 # [\'apple\'] []   (each Cart has its own list)',
    'import time\nfrom dataclasses import dataclass, field\n\n@dataclass\nclass User:\n    username: str\n    password_hash: str = field(repr=False)              # not shown when you print a User\n    created_at: float = field(default_factory=time.time, compare=False)\n    cache: dict = field(default_factory=dict, repr=False, compare=False, init=False)\n\nu = User("surya", "$argon2id$...")\nprint(u)                                                # User(username=\'surya\', created_at=<the current time>)',
]
def _pm_run_earlier(_srcs, _g):
    import sys as _s, io as _io, warnings as _w
    for _src in _srcs:
        _o, _e = _s.stdout, _s.stderr
        _s.stdout = _s.stderr = _io.StringIO()
        try:
            with _w.catch_warnings():
                _w.simplefilter('ignore')
                exec(compile(_src, '<earlier block>', 'exec'), _g)
        except (Exception, SystemExit):
            pass
        finally:
            _s.stdout, _s.stderr = _o, _e
    if 'matplotlib.pyplot' in _s.modules:
        _s.modules['matplotlib.pyplot'].close('all')   # their plots belong to their own blocks
_pm_run_earlier(_pm_earlier, globals())
del _pm_earlier, _pm_run_earlier

4. Comparing dataclasses of different types is always False. The generated __eq__ checks that both objects have the same class first. A Point and a Pixel with the same (x, y) are not equal. This is often what you want, but you should know it.

5. Inheriting from a dataclass with defaults. Once a parent field has a default, every child field must also have one. This is the same rule as for function parameters with defaults. You will meet this error whenever the parent 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. It must be 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 and postcode, all of type 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: check the item's quantity, then append it
        ...


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, so it can be 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 works as a dict key, because frozen=True makes it hashable
warehouses = {addr: "London hub"}
print(warehouses[Address("221B Baker St", "London", "NW1 6XE")])

Three dataclasses in about 30 lines give you validation, a derived total, hashable nested objects, and clear error messages on bad input. With plain classes, you would write much more code.


What You Learned

  • @dataclass generates __init__, __repr__, and __eq__ from annotated fields. Type hints are mandatory.
  • field(default_factory=...) for mutable defaults. The cause is the same as the mutable default argument bug.
  • frozen=True makes a dataclass immutable and hashable. It is shallow: mutable objects inside it can still change.
  • 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 saves memory and blocks new attributes, for classes you create in very large numbers.
  • asdict / astuple convert a dataclass to a dict or tuple, for example before json.dumps.

Next: later in this Intermediate path you will learn decorators and generators, two more tools you will see often in real Python code.

Practice this

on practicepython.in

Short exercises that run in your browser and tell you what your code did, as well as whether a test passed.

Found this useful? Share it with a friend who’s learning Python.