Module 5 · Object-Oriented Programming ⏱ 18 min

Dataclasses for Boilerplate Reduction

By the end of this lesson you will be able to:
  • Decorate a class with @dataclass to auto-generate __init__ and __repr__
  • Declare fields with type hints and default values
  • Compare dataclass instances, which are equal by value
  • Use field(default_factory=...) for mutable defaults and frozen=True for immutability

A plain class that mostly holds data makes you write the same three methods every single time. An __init__ that stores each field. A __repr__ so print shows something useful instead of a memory address. An __eq__ so two records with the same contents count as equal. None of it is hard, but it is relentless — ten fields means ten lines of storage and a comparison that lists all ten.

The @dataclass decorator writes all of that for you, straight from your field declarations:

from dataclasses import dataclass

@dataclass
class Book:
    title: str
    pages: int

b = Book('Dune', 688)
print(b)                      # Book(title='Dune', pages=688)
print(b == Book('Dune', 688))   # True

Each name: type line is a field. The decorator reads them and generates __init__, __repr__, and __eq__ behind the scenes.

flowchart LR
  D["@dataclass<br/>class Book"] --> G["generates __init__, __repr__, __eq__"]
  style D fill:#3776ab,color:#fff
@dataclass reads your field declarations and writes the boilerplate methods.

The boilerplate it replaces

To feel the payoff, picture the same Book written by hand. You would need an __init__ with two self. assignments, a __repr__ that rebuilds the string Book(title=..., pages=...), and an __eq__ that compares both fields and also guards against comparing to something that is not a book at all. That is a dozen lines doing nothing clever.

Slap @dataclass above the same class and those dozen lines vanish. You are left with the part that actually carries information — the list of fields — and Python shoulders the repetitive machinery. Changing a field means editing one line, not hunting through three methods.

Three fields, one with a default. The decorator builds the constructor and repr for you.
from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int
    label: str = 'origin'

p = Point(2, 3)
print(p)
print(p == Point(2, 3))
print(Point(0, 0))

Fields, and what type hints do

Every name: type line declares one field. The part after the colon is a type hint — a note that title should hold a string and pages an integer. Python reads these hints because the decorator needs to know your fields, but it does not enforce them at run time. Hand Book('Dune', 'lots') a string where an int belongs and Python will happily store it; the hint is advice for you and your editor, not a gate the program checks.

That trade-off is deliberate. Hints give you autocomplete, documentation, and tooling that can catch mistakes early, while keeping the language fast and flexible. They are the reason a dataclass reads almost like a filled-in form.

Defaults, and the rule about order

Give a field a default with = value and it becomes optional when you construct the object — callers can leave it out. pages: int = 0 means a book with no page count arrives as zero.

One rule comes with that freedom: fields with defaults must come after fields without them. Write title: str = 'Untitled' above pages: int and Python refuses, because it could no longer match your positional arguments to the right fields. Defaults have to pile up at the end, where their optional nature does no harm.

flowchart LR
  G["title: str"] --> OK["no default, so it comes first"]
  D1["pages: int = 0"] --> LST["has a default, so it comes last"]
  style G fill:#3776ab,color:#fff
  style D1 fill:#3776ab,color:#fff
Fields without a default must come before fields with one — otherwise Python cannot match arguments to fields.
A defaulted field is optional at construction; matching values still compare equal.
from dataclasses import dataclass

@dataclass
class Song:
    title: str
    duration: int = 180

print(Song('Default'))
print(Song('Long', 240))
print(Song('Short') == Song('Short'))

Equal by value, readable when printed

Two dataclass instances holding the same field values are equal — the generated __eq__ compares field by field, not by identity. That is the behaviour you usually want for data: two orders with the same product and quantity should match, even if they were built separately.

The generated __repr__ is just as kind to read. Print a plain class and you get an unhelpful memory address; print a dataclass and you get Book(title='Dune', pages=688), which is exactly how you would construct it again. Good logs and good debugging flow straight from that one line.

flowchart TD
  A["two Books with the same fields"] --> EQ{"== compares values"}
  EQ -- "all fields match" --> T["equal: True"]
  A2["the same two Books"] --> IS{"is compares identity"}
  IS -- "built separately" --> F["identical: False"]
  style T fill:#3776ab,color:#fff
  style F fill:#b45309,color:#fff
== compares field values (True for matching books); is asks whether two values are the very same object (False when each was built separately).

Frozen dataclasses for values that never change

Add frozen=True and a dataclass becomes immutable — once built, its fields cannot be reassigned. That matches data that is meant to be a fixed value: a date, a coordinate, a currency amount. Trying point.x = 5 on a frozen instance raises an error instead of silently rewriting state.

Immutable objects are also hashable, which means they can be used as dictionary keys or set members. That unlocks patterns ordinary mutable objects cannot join, and it makes frozen dataclasses a natural fit for configuration and records that the rest of the program can treat as stable.

A frozen dataclass is immutable and hashable, so equal instances collapse to one set entry.
from dataclasses import dataclass

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

p = Coord(2, 3)
print(p)
print(p == Coord(2, 3))
print(len({p, Coord(2, 3), Coord(9, 9)}))
Exercise

Define a Book dataclass with fields title (str) and pages (int). Let @dataclass generate __init__, __repr__, and __eq__ — you only write the field lines.

from dataclasses import dataclass

@dataclass
class Book:
    # declare the fields: title (str) and pages (int)
    pass
Exercise

What does this print? A dataclass generates __repr__ and value-based __eq__.

from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int

print(Point(2, 3))
print(Point(1, 1) == Point(1, 1))
Exercise

Define a Player dataclass with a name (str) and a level (int) that defaults to 1. Callers should be able to write Player('Ada') and get level 1. Remember the ordering rule: the field WITHOUT a default comes first.

from dataclasses import dataclass

@dataclass
class Player:
    # name first (no default), then level with a default of 1
    pass
Exercise

This Article dataclass tries to give every article a fresh empty tag list, but tags: list = [] is forbidden — it would share one list across all articles. Fix it with field(default_factory=list), and add the import for field.

from dataclasses import dataclass

@dataclass
class Article:
    title: str
    tags: list = []      # BUG: a mutable default is forbidden
Exercise

What does this print? A frozen dataclass is immutable and hashable — so equal instances deduplicate inside a set. Predict all three lines.

from dataclasses import dataclass

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

p = Point(1, 2)
print(p)
print(p == Point(1, 2))
print(len({p, Point(1, 2)}))

Recap

  • @dataclass reads your name: type field lines and generates __init__, __repr__, and __eq__.
  • Type hints document intent but are not enforced at run time.
  • Put defaulted fields after the ones without defaults; for a list or dict default, reach for field(default_factory=...).
  • Two instances with equal fields compare equal by value, and they print in the form you would construct them.
  • frozen=True makes a dataclass immutable and hashable — ideal for fixed values.

Next you will let new kinds of classes inherit behaviour from existing ones, building on the data containers you can now write in a few lines.

Checkpoint quiz

What does adding @dataclass above a class give you for free?

Two dataclass instances built with the same field values compare how under ==?

Why is tags: list = [] rejected inside a dataclass?

Go deeper — technical resources