Python · lesson 18 of 34
Beginner
16- 1 Introduction to Python
- 2 Setting Up Python
- 3 Python Variables and Type System
- 4 Python Data Types
- 5 Python Operators
- 6 Python Strings
- 7 Control Flow in Python
- 8 Python Functions and Parameters
- 9 Python Data Structures
- 10 Python Collections
- 11 Python Modules and Packages
- 12 Object-Oriented Programming in Python
- 13 Encapsulation and Name Mangling in Python
- 14 Polymorphism and Duck Typing in Python
- 15 Abstraction with Abstract Base Classes in Python
- 21 Building a Python CLI Tool
Intermediate
12- 16 Multiple Inheritance and MRO in Python
- 17 Composition vs Inheritance in Python
- 18 Python Dataclasses
- 19 Error Handling in Python
- 20 File I/O, pathlib, and JSON in Python
- 22 Python Decorators and Closures
- 23 Python Generators
- 24 Python Context Managers
- 25 Python Concurrency
- 27 Testing Python Code
- 28 Python Security
- 34 Python Interview Prep
Advanced
6Python Dataclasses
Use the @dataclass decorator to generate __init__, __repr__, and __eq__ automatically, plus frozen instances and post-init validation.
By SkillByExample · Updated
A dataclass is an ordinary class where you declare fields once, as type-annotated class attributes, and @dataclass generates the boilerplate methods for you. Output below is from Python 3.13.
Generated __init__, __repr__, __eq__
Compare a hand-written class against the same thing built with @dataclass.
from dataclasses import dataclass
class PointManual: # hand-written: __init__, __repr__, __eq__ all by hand
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"PointManual(x={self.x!r}, y={self.y!r})"
def __eq__(self, other):
if not isinstance(other, PointManual):
return NotImplemented
return (self.x, self.y) == (other.x, other.y)
@dataclass
class Point: # @dataclass generates __init__, __repr__, __eq__ for you
x: int
y: int
p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1)
print(p1 == p2)
m1 = PointManual(1, 2)
m2 = PointManual(1, 2)
print(m1)
print(m1 == m2)
$ python points.py
Point(x=1, y=2)
True
PointManual(x=1, y=2)
True
Both produce identical behavior — a readable repr and field-by-field equality — but Point needed four lines of field declarations while PointManual needed the same logic spelled out by hand across three methods.
Mutable Defaults, Frozen Instances, and Validation
First, what happens if you give a field a plain list as its default.
from dataclasses import dataclass
try:
@dataclass
class Cart:
owner: str
items: list = [] # looks harmless, but is rejected
except ValueError as e:
print(f"ValueError: {e}")
$ python shared_default.py
ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory
The error fires when the class is defined, not when an instance is created. A plain = [] default would be one list shared by every Cart, the same trap as mutable default arguments, so @dataclass refuses list, dict and set defaults outright and points you at the fix.
field(default_factory=...) is that fix, frozen=True makes instances immutable, and __post_init__ runs right after construction for validation.
from dataclasses import dataclass, field
@dataclass(frozen=True) # frozen - instances become immutable after creation
class Order:
id: int
items: list = field(default_factory=list) # mutable defaults need default_factory
total: float = 0.0
def __post_init__(self):
# runs right after __init__ - a natural place to validate invariants
if self.total < 0:
raise ValueError("total cannot be negative")
o = Order(id=1, items=["book", "pen"])
print(o)
try:
o.total = 50
except Exception as e: # frozen dataclasses raise a dataclasses.FrozenInstanceError
print(f"{type(e).__name__}: {e}")
try:
Order(id=2, total=-10)
except ValueError as e:
print(f"ValueError: {e}")
$ python orders.py
Order(id=1, items=['book', 'pen'], total=0.0)
FrozenInstanceError: cannot assign to field 'total'
ValueError: total cannot be negative
Each Order gets its own fresh items list because default_factory=list calls list() per instance, which is exactly what the ValueError above was asking for. frozen=True blocks the later assignment to total entirely, and __post_init__ catches the invalid -10 before construction ever completes.
Practice
1. Create a Person dataclass with name, age, and an email: str = "" field, and confirm @dataclass includes email in both repr and equality automatically.
Solution
from dataclasses import dataclass
@dataclass
class Person:
name: str
age: int
email: str = ""
p1 = Person("Arjun", 30)
p2 = Person("Arjun", 30, "arjun@example.com")
print(p1)
print(p2)
print(p1 == p2)
$ python person.py
Person(name='Arjun', age=30, email='')
Person(name='Arjun', age=30, email='arjun@example.com')
False
Every annotated field takes part in the generated __repr__ and __eq__, defaulted ones included, so the two people differ only by email and still compare unequal.
2. Remove frozen=True and show that total can now be reassigned to an invalid value. Why is that a liability?
Solution
from dataclasses import dataclass
@dataclass
class MutableOrder:
id: int
total: float = 0.0
def __post_init__(self):
if self.total < 0:
raise ValueError("total cannot be negative")
o = MutableOrder(id=1, total=100)
o.total = -999 # __post_init__ does not run again on assignment
print(o)
$ python mutable_order.py
MutableOrder(id=1, total=-999)
__post_init__ runs once, during construction. Assigning a field afterward bypasses validation entirely, which is why frozen=True plus validation is the safer combination for value objects.
3. Use dataclasses.asdict() to convert an Order into a plain dict, and dataclasses.astuple() to convert it into a tuple.
Solution
from dataclasses import dataclass, field, asdict, astuple
@dataclass(frozen=True)
class Order:
id: int
items: list = field(default_factory=list)
total: float = 0.0
o = Order(id=1, items=["book"], total=25.0)
print(asdict(o))
print(astuple(o))
$ python order_convert.py
{'id': 1, 'items': ['book'], 'total': 25.0}
(1, ['book'], 25.0)
Both helpers walk the fields in declaration order and copy nested containers recursively, which makes asdict() the usual first step before json.dumps().
4. Add order=True to a new Money dataclass with a single amount: float field, then sort a list of Money instances directly.
Solution
from dataclasses import dataclass
@dataclass(order=True)
class Money:
amount: float
amounts = [Money(50), Money(10), Money(30)]
print(sorted(amounts))
print(Money(10) < Money(30))
$ python money.py
[Money(amount=10), Money(amount=30), Money(amount=50)]
True
order=True generates __lt__, __le__, __gt__ and __ge__, comparing fields as a tuple in declaration order, so sorted() works without a custom key function.