Dataclasses
@dataclass writes __init__, __repr__, and __eq__ for you. The right tool for internal data containers — and when to reach for Pydantic instead.
What you'll learn
- @dataclass auto-generates __init__, __repr__, and __eq__
- field() defaults, default_factory, and the mutable-default trap
- frozen=True, kw_only=True, slots=True
- When to pick dataclass vs NamedTuple vs Pydantic vs a plain dict
Before you start
@dataclass is the cheat code for classes that exist mostly to hold data. One decorator reads your annotated fields and writes __init__, __repr__, and __eq__ for you, correctly. Half the classes you would otherwise hand-roll collapse to a handful of lines — and the boilerplate you were most likely to get subtly wrong simply disappears.
The before and after
# Before — the hand-rolled version.
class UserManual:
def __init__(self, id, name, email, is_active=True):
self.id = id
self.name = name
self.email = email
self.is_active = is_active
def __repr__(self):
return (f"User(id={self.id!r}, name={self.name!r}, "
f"email={self.email!r}, is_active={self.is_active!r})")
def __eq__(self, other):
if not isinstance(other, UserManual):
return NotImplemented
return ((self.id, self.name, self.email, self.is_active)
== (other.id, other.name, other.email, other.is_active))
# After — the dataclass version.
from dataclasses import dataclass
@dataclass
class User:
id: int
name: str
email: str
is_active: bool = True
u = User(1, "Aarav", "aarav@example.com")
print(u)
print(u == User(1, "Aarav", "aarav@example.com"))
User(id=1, name='Aarav', email='aarav@example.com', is_active=True)
True
Both define the same class, but the dataclass states it once. The type annotations are load-bearing here: the decorator reads id: int, name: str, and so on to build the __init__ signature in order.
field() and the mutable-default trap
You cannot write tags: list = [] in a dataclass — the decorator refuses, because that is the same shared-mutable-state bug you met with regular classes and default arguments. The fix is default_factory, which runs once per instance:
from dataclasses import dataclass, field
@dataclass
class Post:
title: str
tags: list = field(default_factory=list) # a fresh list per instance
metadata: dict = field(default_factory=dict)
a = Post("hello"); a.tags.append("python")
b = Post("world")
print(a.tags)
print(b.tags)
['python']
[]
b got its own empty list, exactly as it should. field() carries several other useful switches:
field(default=42) # a static default value
field(default_factory=list) # call the factory for each new instance
field(init=False) # leave this field out of __init__
field(repr=False) # hide from __repr__ (good for secrets)
field(compare=False) # exclude from __eq__ and __hash__
frozen, kw_only, slots
Three flags shape the generated class. frozen=True makes instances immutable — assigning to a field raises FrozenInstanceError — and as a bonus generates __hash__, so frozen dataclasses can live in sets and act as dict keys. kw_only=True forces every field to be passed by keyword, which keeps a many-field constructor unambiguous. slots=True adds __slots__ for a little less memory and slightly faster attribute access, worth it on hot-path data:
from dataclasses import dataclass
@dataclass(frozen=True, kw_only=True, slots=True)
class CardOnFile:
"""A saved payment method — immutable, hashable, slotted."""
user_id: int
last4: str
brand: str
exp_month: int
exp_year: int
is_default: bool = False
card = CardOnFile(
user_id=42, last4="4242", brand="visa",
exp_month=11, exp_year=2028, is_default=True,
)
print(card)
# frozen=True — assignment after construction fails.
try:
card.is_default = False
except Exception as e:
print("frozen:", type(e).__name__, "-", e)
# frozen also makes it hashable — usable in a set.
saved = {card, CardOnFile(user_id=42, last4="0001", brand="amex",
exp_month=3, exp_year=2027)}
print("unique cards:", len(saved))
CardOnFile(user_id=42, last4='4242', brand='visa', exp_month=11, exp_year=2028, is_default=True)
frozen: FrozenInstanceError - cannot assign to field 'is_default'
unique cards: 2
frozen=True is a fine default for value objects you pass between functions — immutability removes a whole class of “who mutated this?” bugs and makes code easier to reason about.
Which data container? A decision
Modern Python offers four “bag of fields” shapes, and they look alike but each has a niche. The honest way to choose is to ask where the data comes from:
The reasoning behind each: a dict is trivial but has no schema, so a typo like user["nmae"] fails only when you read it. A NamedTuple is immutable and tuple-compatible — ideal for several named return values. A dataclass is the flexible, mutable-by-default container for internal models you already trust. And Pydantic validates and coerces on construction ("42" becomes 42, a bad email is rejected), which is exactly what you want for data arriving from outside.
A real example — a CardOnFile pipeline
The everyday shape: a dataclass for the internal model, a function that turns raw input into instances, and asdict() to convert back for serialisation.
from dataclasses import dataclass, asdict
@dataclass(frozen=True, kw_only=True, slots=True)
class CardOnFile:
user_id: int
last4: str
brand: str
exp_month: int
exp_year: int
is_default: bool = False
def from_raw(raw):
return CardOnFile(
user_id=int(raw["user_id"]),
last4=raw["card_number"][-4:],
brand=raw["card_brand"].lower(),
exp_month=int(raw["exp_month"]),
exp_year=int(raw["exp_year"]),
is_default=bool(raw.get("default", False)),
)
raw_records = [
{"user_id": "42", "card_number": "4242424242424242", "card_brand": "Visa",
"exp_month": "11", "exp_year": "2028", "default": True},
{"user_id": "42", "card_number": "5555555555554444", "card_brand": "Mastercard",
"exp_month": "07", "exp_year": "2027"},
]
cards = [from_raw(r) for r in raw_records]
for c in cards:
print(c)
print("as dict:", asdict(cards[0]))
CardOnFile(user_id=42, last4='4242', brand='visa', exp_month=11, exp_year=2028, is_default=True)
CardOnFile(user_id=42, last4='4444', brand='mastercard', exp_month=7, exp_year=2027, is_default=False)
as dict: {'user_id': 42, 'last4': '4242', 'brand': 'visa', 'exp_month': 11, 'exp_year': 2028, 'is_default': True}
The dataclass holds the model, from_raw does the messy conversion (note "07" became the integer 7), and asdict() flattens an instance back to a plain dict for JSON — a clean separation of concerns.
post_init, and what you do NOT get
For validation or a derived field after the generated __init__, dataclasses offer __post_init__:
@dataclass
class Range:
low: int
high: int
def __post_init__(self):
if self.low > self.high:
raise ValueError(f"low ({self.low}) > high ({self.high})")
But if you find yourself writing a lot of __post_init__ validation, that is the signal to switch to Pydantic. And be clear-eyed about what @dataclass does not hand you: it generates __init__/__repr__/__eq__, but it does not type-check at runtime (User(id="oops") constructs fine — annotations are hints, not guards), it does not serialise beyond asdict(), and it gives no ordering comparisons unless you pass order=True to generate <, <=, and friends from field order.
In one breath
@dataclassreads annotated fields and writes__init__,__repr__,__eq__.- Never default a mutable field to a literal — use
field(default_factory=...). frozen=Truemakes instances immutable and hashable;kw_only=Trueandslots=Truetidy and speed things up.- Choose by origin: Pydantic at the boundary, dataclass inside, NamedTuple for named returns, dict for scratch.
- Annotations are hints —
@dataclassdoes not enforce types at runtime.
Practice
Quick check
What’s next
Dataclasses document their fields with type annotations. Next we take type hints seriously — how to use them across a codebase to catch a whole class of bugs before the program ever runs.
Practice this in an interview
All questions`@dataclass` auto-generates `__init__`, `__repr__`, and `__eq__` from the field annotations declared in the class body, eliminating boilerplate. Key options include `frozen=True` for immutability and automatic `__hash__`, `order=True` for comparison operators, and `slots=True` (Python 3.10+) for memory-efficient slot-based storage.
First-class functions can be stored in variables, passed as arguments, returned from other functions, and placed in data structures — just like any other object. This is the foundation for higher-order functions, decorators, callbacks, and functional programming patterns in Python.
`__slots__` replaces the per-instance `__dict__` with a fixed-size C array of slot descriptors, cutting memory usage per instance by 40–60% and speeding up attribute access. Use it for classes that create many small, fixed-attribute instances — but be aware it prevents dynamic attribute assignment and complicates multiple inheritance.
Choose a list when order matters and you need indexed access or duplicates. Choose a dict when you need to map keys to values and look up by key in O(1). Choose a set when you need uniqueness, fast membership testing, or set-algebra operations. Getting this choice wrong usually means either incorrect results (keeping duplicates when you needed uniqueness) or avoidable O(n) lookups.