Classes and dataclasses
A class is a way of saying "these values belong together, and here is what you are allowed to do with them". In a backend that is most of the job: a ticket has an id, a customer, a subject, a status, and a small set of legal transitions between statuses. If the transitions live next to the data, every caller gets them right. If they live in whichever function last touched the ticket, they drift.
Python gives you two ways to write such a class. The long way, with __init__, __repr__ and __eq__ typed out by hand, and the short way, @dataclass, which generates those methods from the field list. For anything that is mostly data, use the short way. Behaviour goes in ordinary methods either way.
The Ticket dataclass
Relay's core record is a ticket. Here it is with a status enum, sensible defaults, and one method that enforces a rule.
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import StrEnum
class Status(StrEnum):
OPEN = "open"
ESCALATED = "escalated"
CLOSED = "closed"
@dataclass
class Ticket:
id: int
customer_id: int
subject: str
body: str
status: Status = Status.OPEN
tags: list[str] = field(default_factory=list)
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
def escalate(self, reason: str) -> None:
if self.status is Status.CLOSED:
raise ValueError(f"ticket {self.id} is closed")
self.status = Status.ESCALATED
self.tags.append(f"escalated:{reason}")
Three things are worth noticing. The field list is the whole constructor: Ticket(id=1, customer_id=7, subject="Login fails", body="...") works, positional or keyword, with the defaults filled in. field(default_factory=list) exists because a bare tags: list[str] = [] would share one list between every ticket; dataclasses refuse it at class-definition time, which is the right call. And escalate is where the rule lives. Nobody else needs to remember that closed tickets cannot be escalated.
Because __repr__ and __eq__ are generated, print(ticket) shows every field, and two tickets with the same values compare equal. That last point matters in tests, where you build an expected ticket and compare it to the one your code produced.
The options that change behaviour
@dataclass takes arguments. Four of them come up constantly.
| Option | What it does | Use it when |
|---|---|---|
frozen=True |
Assignment after construction raises | The value is an identity or a config record |
slots=True |
Stores fields in slots, not a dict | Many instances, or you want typos on assignment to fail |
kw_only=True |
Constructor accepts keyword arguments only | More than three or four fields |
order=True |
Generates <, <= and friends by field order |
You sort these objects |
A frozen dataclass is also hashable, so it can be a dict key or a set member. Relay's Customer is a good candidate: identity data that should not change under you halfway through a request.
@dataclass(frozen=True, slots=True, kw_only=True)
class Customer:
id: int
email: str
plan: str = "free"
def __post_init__(self) -> None:
if "@" not in self.email:
raise ValueError(f"not an email address: {self.email!r}")
@classmethod
def from_row(cls, row: dict) -> "Customer":
return cls(id=row["id"], email=row["email"], plan=row.get("plan", "free"))
__post_init__ runs after the generated constructor, which makes it the place for checks that need more than one field or that a type hint cannot express. from_row is the usual pattern for building an object from a database row or a JSON document: a classmethod named for its source, so the mapping is written once.
Engineering note. Dataclasses do not validate types.
Ticket(id="12", ...)succeeds and you carry a string around until something downstream breaks. For data crossing a boundary, request bodies, rows, model output, use a Pydantic model, which validates and converts. Keep dataclasses for objects your own code constructs.
Plain classes still have a place. When the object is mostly behaviour with a little state, a connection pool, a rate limiter, a client wrapper, write __init__ by hand and skip the decorator. The dataclass machinery is for records.
Where it goes wrong
- A mutable default that is not caught.
tags: list[str] = []is rejected, butmeta: dict = {}is too, and a default that is a custom mutable object is not. Any default that is not a number, string, tuple orNoneshould be adefault_factory. - Fields out of order. A field without a default cannot follow one with a default; the class fails to define. Either reorder, or use
kw_only=True, which lifts the restriction. - Mutating a frozen instance through a mutable field.
frozen=Truestopscustomer.plan = "pro", but if a frozen class holds a list,customer.tags.append(...)still works. Frozen is shallow. - Comparing by identity when you meant value.
ischecks whether two names refer to the same object. Two equal tickets are==but notis. Useisonly for enums,Noneand sentinels. - Business rules scattered outside the class. If three call sites each check
status != "closed"before escalating, one of them will eventually use a typo. Put the rule in the method and call the method.
Try it
Create relay/tickets.py with the Status, Ticket and Customer classes above. Add a Ticket.close() method that refuses to close a ticket that is already closed, and a Ticket.from_row(row: dict) classmethod that builds a ticket from a dict with string keys and a string status, converting the status with Status(row["status"]). Then start uv run python, build two tickets, escalate one, and print both to see the generated __repr__ at work. Notice what happens if you pass a status string that is not in the enum.
Next, the type hints that pay off, where those field annotations start earning their keep beyond the constructor.
Continue with the complete track
Keep your progress and unlock the surrounding lessons, exercises, and complete learning path.
Unlock the complete track