RizTech Academy logo
RizTech Academy
Object-Oriented PythonLesson 6 of 720 min

Dataclasses: less boilerplate

Look at what a simple data-holding class costs:

class Expense:
    def __init__(self, amount: float, category: str, note: str = ""):
        self.amount = amount
        self.category = category
        self.note = note

    def __repr__(self) -> str:
        return (f"Expense(amount={self.amount!r}, category={self.category!r}, "
                f"note={self.note!r})")

    def __eq__(self, other) -> bool:
        if not isinstance(other, Expense):
            return NotImplemented
        return ((self.amount, self.category, self.note)
                == (other.amount, other.category, other.note))

Every field written three times, and the whole thing is mechanical. Worse, it rots — add a field and forget to update __eq__, and you have a subtle bug with no error.

The same thing as a dataclass

from dataclasses import dataclass


@dataclass
class Expense:
    amount: float
    category: str
    note: str = ""

That is equivalent, and better — __init__, __repr__ and __eq__ are generated from the fields, so they cannot drift.

e = Expense(250, "food", "lunch")
print(e)
print(e == Expense(250, "food", "lunch"))
Expense(amount=250, category='food', note='lunch')
True

The type hints are required. A dataclass finds its fields by looking at the annotations, so a field without one is silently ignored. They are still not enforced at runtime — Expense("abc", "food") works, exactly as module 5 said.

Defaults work as normal, and as normal must come after fields without them.

Methods still work

A dataclass is an ordinary class with some methods written for you:

@dataclass
class Expense:
    amount: float
    category: str
    note: str = ""

    GST_RATE = 0.18        # no annotation, so not a field

    def with_gst(self) -> float:
        return round(self.amount * (1 + self.GST_RATE), 2)

    def describe(self) -> str:
        return f"{self.category}: ₹{self.amount:,.2f}"

GST_RATE has no annotation, so it is an ordinary class attribute rather than a constructor parameter. That is the mechanism, and it is easy to trip over in both directions.

Validation

__post_init__ runs after the generated __init__:

@dataclass
class Expense:
    amount: float
    category: str
    note: str = ""

    def __post_init__(self) -> None:
        if self.amount < 0:
            raise ValueError(f"Amount cannot be negative, got {self.amount}")
        if self.category not in {"food", "transport", "rent", "other"}:
            raise ValueError(f"Unknown category: {self.category!r}")

The invariants from the first lesson, with none of the boilerplate.

The mutable default, again

@dataclass
class Report:
    expenses: list = []
ValueError: mutable default <class 'list'> for field expenses is not allowed:
use default_factory

Python refuses at class-definition time. This is module 5's mutable default trap, and dataclasses are the one place it is caught for you rather than producing a silent bug.

from dataclasses import dataclass, field


@dataclass
class Report:
    expenses: list[Expense] = field(default_factory=list)

default_factory=list calls list() for each new object — a fresh list every time, which is what basket=None achieved manually.

Frozen dataclasses

@dataclass(frozen=True)
class Point:
    x: int
    y: int


p = Point(1, 2)
p.x = 5
FrozenInstanceError: cannot assign to field 'x'

Immutable after creation, and frozen dataclasses are hashable, so they work as dictionary keys and in sets — without writing __hash__.

That makes them an excellent replacement for tuples when the fields deserve names. Module 4's coordinate key:

locations = {Point(19.07, 72.87): "Mumbai"}

point.x beats point[0], and it is still a valid key.

Use frozen=True for value objects — coordinates, money, configuration, anything that represents a value rather than a thing with a lifecycle.

Useful options

@dataclass(frozen=True, order=True, slots=True)
class Point:
    x: int
    y: int

order=True generates <, <=, >, >=, comparing fields in order — so sorting works with no __lt__.

slots=True (Python 3.10+) uses less memory and makes attribute access marginally faster, at the cost of preventing new attributes being added later. Reasonable for objects you create in large numbers.

Per-field control:

@dataclass
class User:
    name: str
    email: str
    password_hash: str = field(repr=False)
    created_at: datetime = field(default_factory=datetime.now)

repr=False keeps the password hash out of __repr__ — so it never lands in a log or an error message. That is a small habit with real security value.

Converting to and from dictionaries

from dataclasses import asdict, astuple

print(asdict(e))
{'amount': 250, 'category': 'food', 'note': 'lunch'}

asdict works recursively through nested dataclasses, which makes saving to JSON straightforward:

json.dump(asdict(report), file, indent=2)

Coming back the other way, the from_dict classmethod from the methods lesson:

    @classmethod
    def from_dict(cls, data: dict) -> "Expense":
        return cls(**data)

**data spreads the dictionary into keyword arguments, from module 5. That works when the keys match the field names exactly — and raises TypeError on an unexpected key, which is a reasonable way to catch bad data early.

When not to use one

When the class is mostly behaviour. A PaymentProcessor with one field and six methods is not a data class, and the decorator adds nothing.

When you need full control over __init__ — complex setup, several alternative constructors, work beyond validation.

When you need a different __eq__ than field-by-field comparison. Two users being equal because their ids match, regardless of other fields, needs writing by hand.

The alternatives

NamedTuple is a lighter option for immutable records:

from typing import NamedTuple


class Point(NamedTuple):
    x: int
    y: int

Immutable, hashable, and unpackable like a tuple. A frozen dataclass is more flexible; NamedTuple is better when you want tuple behaviour.

Pydantic is worth knowing by name. It looks like a dataclass and does enforce types at runtime, converting and validating input. That is what you want at the edge of a system — parsing JSON from an API or a request body — and it is what FastAPI is built on. Not in the standard library, and the right tool when untrusted data arrives.

Check your work

Line count. The handwritten version is roughly 15 lines for three fields; the dataclass is 5. More importantly, the handwritten one repeats each field three times, so it rots.

Forgetting __eq__ when adding a field means two objects differing only in the new field compare equal. A subtle bug with no error — and the dataclass version cannot have it, because __eq__ is generated from the fields.

Class attributes without annotations are not fields.

GST_RATE = 0.18          # not a constructor parameter
amount: float            # a field

Easy to trip over in both directions: a field without an annotation is silently ignored.

The mutable default is caught at definition time.

ValueError: mutable default <class 'list'> for field expenses is not allowed:
use default_factory

This is the one place Python catches module 5's trap for you rather than letting it become a silent bug. field(default_factory=list) calls list() per object.

Frozen dataclasses are hashable, so they work as dictionary keys and in sets without writing __hash__. That makes them an excellent replacement for tuples when the fields deserve names — point.x beats point[0].

order=True generates the comparison methods, so sorting works with no __lt__.

field(repr=False) keeps a password hash out of __repr__, so it never lands in a log or an error message. A small habit with real security value.

The round trip loses the same things module 7 warned about: tuples become lists, and non-string keys become strings.

Practice

  1. Write Expense by hand with __init__, __repr__ and __eq__. Count the lines. Rewrite it as a dataclass and count again.
  2. Add a field to the handwritten version and deliberately forget __eq__. Watch two different objects compare equal.
  3. Add methods and a class-level constant to a dataclass. Confirm the constant is not a constructor parameter.
  4. Add __post_init__ validation and test it rejects a negative amount.
  5. Try expenses: list = [] and read the error. Fix it with default_factory.
  6. Make a frozen dataclass and try to modify it. Then use it as a dictionary key.
  7. Add order=True and sort a list of them without writing __lt__.
  8. Use field(repr=False) on a sensitive field and confirm it stays out of the output.
  9. Round-trip a dataclass through asdict, json.dumps, json.loads and cls(**data). Check which types survive, using module 7's warnings.

Next: the most useful lesson in this module — when not to write a class at all.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship