Dataclasses: Structure Without Boilerplate
A form with named boxes, not a blank page
In this lesson
You have been carrying records around as dicts since Unit 7 and as tuples since Unit 8. Both work, and both let a typo become a silent bug. A dataclass gives you a real type with named fields for about three lines of code, and it pairs naturally with the type hints from the last lesson.
Explain it like I’m 5
A dataclass is a form with named boxes. A dictionary is a blank page where you hope everyone spells the labels the same way.
Three lines, and the methods are written for you
Decorate a class with @dataclass and list its fields with type hints. Python generates __init__, __repr__, and __eq__ from that list, the same methods you wrote out by hand in Unit 4.
__repr__ alone justifies it. Printing a dataclass shows every field and its value, which turns debugging from guesswork into reading.
from dataclasses import dataclass
@dataclass
class Book:
title: str
year: int
book = Book("Dune", 1965)
print(book)
print(book.title, book.year)
print(Book("Dune", 1965) == book)
Book(title='Dune', year=1965) Dune 1965 True
Defaults, and the mutable-default trap
Fields can have defaults, written just as you would in a function. Fields with defaults must come after those without.
One field type needs care. You cannot write tags: list[str] = [], because that single list would be shared by every instance, the same mutable-default pitfall from Unit 4. Dataclasses catch this and raise an error rather than letting it happen; use field(default_factory=list), which makes a fresh list per instance.
from dataclasses import dataclass, field
@dataclass
class Book:
title: str
year: int
in_print: bool = True
tags: list[str] = field(default_factory=list)
a = Book("Dune", 1965)
b = Book("Neuromancer", 1984)
a.tags.append("classic")
print(a)
print(b.tags) # still empty: each instance got its own list
Book(title='Dune', year=1965, in_print=True, tags=['classic']) []
Four ways to hold a record
It is worth seeing the options side by side, because each is right somewhere:
- A dict — flexible, no definition needed, and a typo in a key is a silent bug found at runtime.
- A tuple, as Unit 8's rows came back — compact, but
row[2]tells the reader nothing and breaks quietly if the query is reordered. - A hand-written class, as in Unit 4 — full control, at the cost of writing the methods yourself.
- A dataclass — named fields, generated methods, and a type a checker understands.
Dicts remain the right choice for genuinely dynamic data such as a raw API response. The moment the shape is known and stable, a dataclass is better.
from dataclasses import dataclass
# Unit 8 handed rows back as tuples:
row = ("Dune", 1965)
print(row[0], row[1]) # what is row[1] again?
@dataclass
class Book:
title: str
year: int
book = Book(*row) # unpack the tuple into named fields
print(book.title, book.year) # now it says what it means
Dune 1965 Dune 1965
Take the flattened API records from Unit 7 and give them a real type. Define a Book dataclass with title, year, and a tags list defaulting to empty, then build one per record and print each.
from dataclasses import dataclass, field
RECORDS = [
{"title": "Dune", "year": 1965},
{"title": "Neuromancer", "year": 1984},
]
# TODO: define the Book dataclass
books = [] # TODO: build one Book per record
for book in books:
print(book)
Decorate the class with @dataclass and give tags the value field(default_factory=list). Then build the list with a comprehension passing record["title"] and record["year"].
from dataclasses import dataclass, field
RECORDS = [
{"title": "Dune", "year": 1965},
{"title": "Neuromancer", "year": 1984},
]
@dataclass
class Book:
title: str
year: int
tags: list[str] = field(default_factory=list)
books = [Book(record["title"], record["year"]) for record in RECORDS]
for book in books:
print(book)
Book(title='Dune', year=1965, tags=[])
Book(title='Neuromancer', year=1984, tags=[])
Storing them back in the database
This closes the loop with Unit 8. A dataclass goes into a parameterized INSERT by reading its fields, and comes back out by unpacking the row into the constructor. The database still stores plain columns; your program just stops handling loose tuples.
import sqlite3
from dataclasses import dataclass
@dataclass
class Book:
title: str
year: int
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE books (title TEXT, year INTEGER)")
books = [Book("Dune", 1965), Book("Neuromancer", 1984)]
for book in books:
conn.execute("INSERT INTO books VALUES (?, ?)", (book.title, book.year))
conn.commit()
loaded = [Book(*row) for row in conn.execute("SELECT title, year FROM books")]
print(loaded)
print(loaded == books)
[Book(title='Dune', year=1965), Book(title='Neuromancer', year=1984)] True
Common mistake: Writing tags: list[str] = [] as a default
It is how you would write a default anywhere else.
Use field(default_factory=list). A single shared list across all instances is the Unit 4 mutable-default bug; dataclasses refuse it outright.
Common mistake: Putting a field with a default before one without
The field order is chosen for readability rather than for the generated __init__.
Required fields first, defaulted fields after. The generated __init__ follows normal Python argument rules.
Common mistake: Reaching for a dataclass for genuinely dynamic data
Having just learned it, everything looks like a record.
A raw API response with unpredictable keys is a dict. Convert to a dataclass at the point where you have decided which fields you actually care about.
Common mistake: Expecting frozen=True to freeze nested values
‘Frozen’ sounds total.
It only stops assigning to the object's own fields. A list inside a frozen dataclass can still be appended to.
Which methods does @dataclass generate for you?
It writes the constructor, a readable repr, and field-by-field equality from the annotated fields.
Why must a list field use field(default_factory=list)?
A plain = [] default would be created once and shared by every instance, the classic mutable-default bug.
When is a plain dict still the better choice?
Dicts suit unpredictable shapes. Once the shape is known and stable, a dataclass gives you names and checking.
Mini exercise (medium)
Define a LogEvent dataclass with day, hour, level, and message, frozen so parsed events cannot be edited. Write to_event(row) building one from a Unit 8 row tuple, and errors_only(events) returning just those with level "ERROR".
Take the wheel. Complete the code, hit Run, and check your output right here.
from dataclasses import dataclass
ROWS = [
("2026-07-27", "09", "ERROR", "disk full"),
("2026-07-27", "09", "INFO", "startup"),
("2026-07-27", "10", "ERROR", "missing file"),
]
# TODO: a frozen LogEvent dataclass with day, hour, level, message
def to_event(row):
return None # TODO: build a LogEvent from the row tuple
def errors_only(events):
return [] # TODO: keep only level == "ERROR"
for event in errors_only([to_event(row) for row in ROWS]):
print(event.hour, event.message)
@dataclass(frozen=True) takes the argument in the decorator. LogEvent(*row) unpacks a four-item tuple straight into the four fields.
from dataclasses import dataclass
ROWS = [
("2026-07-27", "09", "ERROR", "disk full"),
("2026-07-27", "09", "INFO", "startup"),
("2026-07-27", "10", "ERROR", "missing file"),
]
@dataclass(frozen=True)
class LogEvent:
day: str
hour: str
level: str
message: str
def to_event(row):
return LogEvent(*row)
def errors_only(events):
return [event for event in events if event.level == "ERROR"]
for event in errors_only([to_event(row) for row in ROWS]):
print(event.hour, event.message)
09 disk full
10 missing file
event = to_event(("d", "h", "INFO", "m"))
assert event.level == "INFO", "fields should be filled in order"
try:
event.level = "ERROR"
except Exception:
pass
else:
raise AssertionError("a frozen dataclass should refuse assignment")
assert errors_only([event]) == [], "an INFO event is not an error"
print("✓ Looks good!")