Dataclasses: Structure Without Boilerplate

A form with named boxes, not a blank page

Advanced 12 min

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.

Example
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)
Output
Book(title='Dune', year=1965)
Dune 1965
True
No __init__ written, and equality compares by value.

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.

Example
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
Output
Book(title='Dune', year=1965, in_print=True, tags=['classic'])
[]
default_factory gives every instance its own list.

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.

Example
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
Output
Dune 1965
Dune 1965
Same data, same output, very different readability.

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)

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.

Example
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)
Output
[Book(title='Dune', year=1965), Book(title='Neuromancer', year=1984)]
True
Out to the database as columns, back in as objects.

Common mistake: Writing tags: list[str] = [] as a default

Why it happens:

It is how you would write a default anywhere else.

How to fix it:

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

Why it happens:

The field order is chosen for readability rather than for the generated __init__.

How to fix it:

Required fields first, defaulted fields after. The generated __init__ follows normal Python argument rules.

Common mistake: Reaching for a dataclass for genuinely dynamic data

Why it happens:

Having just learned it, everything looks like a record.

How to fix it:

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

Why it happens:

‘Frozen’ sounds total.

How to fix it:

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?

Why must a list field use field(default_factory=list)?

When is a plain dict still the better choice?

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)

What to learn next

You gave your records a real type: @dataclass generating __init__, __repr__, and __eq__ from the annotated fields, field(default_factory=list) for mutable defaults, frozen=True for records that shouldn’t change, and a round trip storing dataclasses in the SQLite table from Unit 8 and rebuilding them on the way out.

All four habits are now in hand. The Unit 9 Project applies every one of them to the script you built in Units 7 and 8.