datarekha

Dataclasses

@dataclass writes __init__, __repr__, and __eq__ for you. The right tool for internal data containers — and when to reach for Pydantic instead.

9 min read Intermediate Python Lesson 28 of 41

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:

crosses a boundary (API, config, env vars)Pydanticinternal model passed between your functionsdataclassseveral named return values, immutableNamedTuplequick scratch / one-shot datadict

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

  • @dataclass reads annotated fields and writes __init__, __repr__, __eq__.
  • Never default a mutable field to a literal — use field(default_factory=...).
  • frozen=True makes instances immutable and hashable; kw_only=True and slots=True tidy and speed things up.
  • Choose by origin: Pydantic at the boundary, dataclass inside, NamedTuple for named returns, dict for scratch.
  • Annotations are hints — @dataclass does not enforce types at runtime.

Practice

Quick check

0/3
Q1Why must you use `field(default_factory=list)` instead of `tags: list = []`?
Q2When should you reach for Pydantic instead of a dataclass?
Q3What does `@dataclass(frozen=True)` give you besides immutability?

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.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
What does `@dataclass` give you over a plain class, and what are its main configuration options?

`@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.

What does it mean for functions to be first-class objects in Python?

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.

What does `__slots__` do in Python, and when should you use it?

`__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.

Given a new data problem, how do you decide whether to use a list, dict, or set?

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.

Related lessons

Explore further

Skip to content