Dataclasses and Data Modeling

Updated

September 8, 2026

Dataclasses and Data Modeling

A dataclass is a class whose job is to hold fields. The boring default for a ticket that has a stable shape is @dataclass with named fields, not a dict you poke with string keys, and not a hand-written class with ten identical __init__ lines.

Field names on a dataclass need a type (the decorator uses the annotations to know what the fields are). That is the exception in this part of the book. Functions still have no type hints. Full typing comes later.

Mental model

@dataclass generates __init__, __repr__, and __eq__ from the field list. Two tickets with the same field values compare equal. The repr prints the class name and fields, which is what you want in a failure.

Defaults go after fields without defaults. Mutable defaults still need field(default_factory=list) — the same trap as function arguments, with the same fix.

replace(ticket, status="paid") returns a new instance. asdict(ticket) is a dict for JSON-shaped edges. Neither is magic: nested dataclasses become nested dicts only if you ask (asdict).

frozen=True makes assignment to fields raise. Use it when the ticket should not change in place (good for dict keys if all fields are hashable). The default is mutable, which matches a ticket that moves from "open" to "paid".

A dataclass is still a class. Put tiny methods on it when the behavior belongs to the ticket (is_open, label). Keep I/O and kitchen routing as functions.

Worked examples

Case 1: A ticket with fields

Save as ticket_dc.py.

# ticket_dc.py
from dataclasses import dataclass


@dataclass
class Ticket:
    id: int
    table: int
    status: str = "open"
    cents: int = 0


def main():
    t = Ticket(7, 12, cents=1850)
    print(t)
    print(t.id, t.table, t.status)
    t.status = "paid"
    print(t)
    print(Ticket(7, 12, "open", 1850) == Ticket(7, 12, "open", 1850))


if __name__ == "__main__":
    main()

Run:

uv run python ticket_dc.py

Output:

Ticket(id=7, table=12, status='open', cents=1850)
7 12 open
Ticket(id=7, table=12, status='paid', cents=1850)
True

Ticket(7, 12, cents=1850) uses the default status. Equality is by value, not by identity. Two separately constructed tickets with the same numbers are equal.

Case 2: Methods that belong to the ticket

# ticket_label.py
from dataclasses import dataclass


@dataclass
class Ticket:
    id: int
    table: int
    status: str
    cents: int

    def label(self):
        return f"ticket {self.id} → table {self.table}"

    def is_open(self):
        return self.status == "open"


def main():
    t = Ticket(7, 12, "open", 1850)
    print(t.label())
    print(t.is_open())
    t.status = "paid"
    print(t.is_open())


if __name__ == "__main__":
    main()

Run:

uv run python ticket_label.py

Output:

ticket 7 → table 12
True
False

label and is_open read the fields. They do not print, they do not hit the network. That is the size of method you want on a dataclass.

Case 3: replace, asdict, and a list factory

# ticket_replace.py
from dataclasses import asdict, dataclass, field, replace


@dataclass
class Ticket:
    id: int
    table: int
    status: str = "open"
    notes: list = field(default_factory=list)


def main():
    t = Ticket(7, 12)
    t.notes.append("window")
    paid = replace(t, status="paid")
    print(t)
    print(paid)
    print(asdict(paid))
    u = Ticket(8, 3)
    print(u.notes)


if __name__ == "__main__":
    main()

Run:

uv run python ticket_replace.py

Output:

Ticket(id=7, table=12, status='open', notes=['window'])
Ticket(id=7, table=12, status='paid', notes=['window'])
{'id': 7, 'table': 12, 'status': 'paid', 'notes': ['window']}
[]

replace copied the notes list object. paid.notes is t.notes is True. If notes must fork, pass a new list into replace. default_factory=list gives ticket 8 its own empty list — not the sticky shared list from a notes: list = [] default.

The trap

A mutable default on a field is the same sticky-list bug as a default argument. Dataclasses refuse it at class definition.

# sticky_field.py
from dataclasses import dataclass


@dataclass
class Ticket:
    id: int
    notes: list = []


def main():
    print(Ticket(7))


if __name__ == "__main__":
    main()

Run:

uv run python sticky_field.py

Output (the process exits non-zero):

Traceback (most recent call last):
  File "sticky_field.py", line 5, in <module>
    @dataclass
  File ".../dataclasses.py", line 917, in _get_field
    raise ValueError(...)
ValueError: mutable default <class 'list'> for field notes is not allowed: use default_factory

The decorator is doing you a favor. The fix is Case 3: notes: list = field(default_factory=list).

replace still shares nested lists: paid.notes is t.notes. Copy the list when the new ticket must own its notes.

The boring rule

  • Stable shape → dataclass. Ad-hoc bag → dict, then promote.
  • Annotate fields (id: int). Do not decorate every function yet.
  • field(default_factory=list) for mutable defaults.
  • Tiny methods for labels and predicates. Functions for workflows.
  • replace for a changed copy. Remember nested lists are still aliases.
  • frozen=True when the record should not move in place.

Try this

  1. Add a covers: int = 0 field to Ticket in ticket_dc.py and print it.
  2. In ticket_replace.py, replace(t, notes=list(t.notes)) then append "allergy" only on paid. Confirm t.notes did not gain it.
  3. Rebuild Ticket with @dataclass(frozen=True) in a copy of ticket_dc.py. Assign t.status = "paid" and read the exception.