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
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.
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
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
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.
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)}))
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
Each field is a line: name colon type.
Two lines: title: str and pages: int. The decorator handles the rest.
from dataclasses import dataclass
@dataclass
class Book:
title: str
pages: int
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))
print uses the generated repr.
Equal field values compare equal under the generated eq.
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
Declare name: str on the first field line.
Give level a default: level: int = 1. Defaulted fields go last.
from dataclasses import dataclass
@dataclass
class Player:
name: str
level: int = 1
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
Import field alongside dataclass: from dataclasses import dataclass, field.
Replace = [] with = field(default_factory=list) so each instance gets its own list.
from dataclasses import dataclass, field
@dataclass
class Article:
title: str
tags: list = field(default_factory=list)
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)}))
print uses the generated repr.
Equal frozen instances are also hashable, so the set keeps only one of them.
Recap
@dataclassreads yourname: typefield 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=Truemakes 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.