Modern Dataclasses for Domain Models
15 min · Beginner
Need help? Ask a tutor on WhatsApp (opens in a new tab)Introduced in Python 3.7 (PEP 557), dataclasses eliminate repetitive constructor and representation boilerplate. Instead of manually writing __init__, __repr__, and __eq__ for every domain model, decorating a class with @dataclass generates them automatically from type annotations.
The Power of @dataclass
Dataclass is a class decorated with @dataclass. Python writes __init__, __repr__ and __eq__ for it from the annotated fields.
Declare attributes with type hints, and Python writes all the boilerplate for you.
from dataclasses import dataclass
@dataclassclass User: id: int username: str email: str is_active: bool = True
u = User(1, "alex", "alex@example.com")print(u) # User(id=1, username='alex', email='alex@example.com', is_active=True)Example 1 · A model in four lines
from dataclasses import dataclass
@dataclassclass Product: name: str price: float stock: int = 0
print(Product("Notebook", 75.0))print(Product("Pen", 10.0, 120))Product(name='Notebook', price=75.0, stock=0) Product(name='Pen', price=10.0, stock=120)
What this code does
- Fields with a default (
stock) must come after fields without one. - The printed text comes from the generated
__repr__.
@dataclass automatically wrote __init__, an informative __repr__, and a value-based __eq__.
Automatic Value Equality
Dataclasses compare by value out of the box without writing any __eq__ code.
from dataclasses import dataclass
@dataclassclass User: id: int username: str
u1 = User(1, "alex")u2 = User(1, "alex")print(u1 == u2)print(u1 is u2)Example 2 · Equal by value, different objects
from dataclasses import dataclass
@dataclassclass Customer: customer_id: int name: str
a = Customer(5, "Meera")b = Customer(5, "Meera")print(a == b)print(a is b)print(a == Customer(6, "Meera"))True False False
What this code does
==compares the fields in order;isstill asks if both names point to one object.- One different field makes the objects unequal.
Attributes are compared in the order declared in the class definition.
Default Factories with field()
Default factory is a function that builds a fresh default value for every new object, such as list. It avoids one shared mutable default.
Never use mutable defaults directly! Use field(default_factory=list) to generate fresh collections per instance.
from dataclasses import dataclass, field
@dataclassclass Order: order_id: str items: list[str] = field(default_factory=list)
a = Order("A1")b = Order("A2")a.items.append("Notebook")print(a.items, b.items)Example 3 · Defaults and a list per object
from dataclasses import dataclass, field
@dataclassclass ServerConfig: host: str port: int = 8000 tags: list = field(default_factory=lambda: ["api", "v1"])
cfg = ServerConfig("localhost")print(f"Server on {cfg.host}:{cfg.port} with tags {cfg.tags}")Server on localhost:8000 with tags ['api', 'v1']
What this code does
- Type annotations define attributes.
portreceives default value 8000.tagsreceives independent list fromdefault_factory.
Each Order instance gets its own independent list.
Immutable Value Objects with frozen=True
Frozen dataclass is a dataclass with frozen=True. Its fields cannot be changed after creation, and it gets a __hash__ so it can be a dict key.
Pass frozen=True to make instances immutable and hashable.
from dataclasses import dataclass
@dataclass(frozen=True)class Point: x: float y: float
pt = Point(10.0, 20.0)point_map = {pt: "Origin"}print(point_map[Point(10.0, 20.0)])Example 4 · Frozen: safe to share
from dataclasses import dataclass, replace, FrozenInstanceError
@dataclass(frozen=True)class Money: amount: int currency: str
price = Money(100, "USD")try: price.amount = 120except FrozenInstanceError: print("cannot change")higher = replace(price, amount=120)print(price, higher)print({price: "base", higher: "raised"}[Money(120, "USD")])cannot change Money(amount=100, currency='USD') Money(amount=120, currency='USD') raised
What this code does
- Assigning to a frozen field raises
FrozenInstanceError. replace()builds a new object with one field changed, and equal objects find the same dict key.
Frozen dataclasses are thread-safe and can safely be used as dictionary keys or stored in sets.
Validation with __post_init__
__post_init__ is a method that a dataclass calls right after the generated __init__ finishes. Use it to check or adjust field values.
The generated __init__ accepts any values. To reject bad data, add __post_init__. It runs right after the fields are set.
Example 5 · Reject bad data early
from dataclasses import dataclass
@dataclassclass Booking: guest: str nights: int
def __post_init__(self): if self.nights < 1: raise ValueError("nights must be at least 1")
for nights in (3, 0): try: print(Booking("Aarav", nights)) except ValueError as error: print("Rejected:", error)Booking(guest='Aarav', nights=3) Rejected: nights must be at least 1
What this code does
- The check runs on every new object, so an invalid
Bookingis never created. - The loop tries one valid and one invalid value.
Example 6 · Rejecting a bad price
from dataclasses import dataclass
@dataclassclass Product: name: str price: float
def __post_init__(self): if self.price < 0: raise ValueError("price cannot be negative")
print(Product("Notebook", 75))try: Product("Pen", -5)except ValueError as error: print(error)Product(name='Notebook', price=75) price cannot be negative
What this code does
__post_init__runs right after the generated__init__.- The valid product prints normally; the negative price raises
ValueError, which theexceptblock prints.
Type hints alone are not checked at run time. Rules like "price is not negative" belong in __post_init__.
Next step
Check your understanding
11 quick questions