Dataclasses: the class that is mostly data, written in four lines
The boilerplate
A class that mainly holds data needs the same three methods every time:
class Usage:
def __init__(self, input_tokens, output_tokens, model):
self.input_tokens = input_tokens
self.output_tokens = output_tokens
self.model = model
def __repr__(self):
return f"Usage(input_tokens={self.input_tokens!r}, output_tokens={self.output_tokens!r}, model={self.model!r})"
def __eq__(self, other):
return (self.input_tokens, self.output_tokens, self.model) == \
(other.input_tokens, other.output_tokens, other.model)Fifteen lines, and every one of them can be derived from the three field names. dataclasses derives them:
from dataclasses import dataclass
@dataclass
class Usage:
input_tokens: int
output_tokens: int
model: strSame __init__, same __repr__, same __eq__, generated at class-definition time from the annotated fields. The type annotations are what mark a line as a field; they are not checked at runtime, exactly as module 3 said of hints in general.
u = Usage(2000, 500, "gpt-4o-mini")
print(u) # Usage(input_tokens=2000, output_tokens=500, model='gpt-4o-mini')
print(u == Usage(2000, 500, "gpt-4o-mini")) # TrueYou can still add methods. A dataclass is an ordinary class with some methods written for you:
def cost(self, prices):
p = prices[self.model]
return (self.input_tokens * p["in"] + self.output_tokens * p["out"]) / 1e6Defaults, and the mutable one
Fields with defaults go after fields without, as with function arguments:
@dataclass
class Job:
model: str
max_tokens: int = 500
temperature: float = 0.0Now the list:
@dataclass
class Job:
model: str
prompts: list[str] = [] # ValueError at class definitionThe dataclass refuses. It is protecting you from the shared-list trap of the previous lesson: [] evaluated once at definition time would become the default for every instance. The fix is a factory, a function called once per instance:
from dataclasses import field
@dataclass
class Job:
model: str
prompts: list[str] = field(default_factory=list)
options: dict = field(default_factory=dict)default_factory=list calls list() for each new Job. Any zero-argument callable works, including a lambda that builds something more elaborate.
Frozen
@dataclass(frozen=True)
class ModelSpec:
name: str
context_window: int
price_in: float
price_out: floatAssigning to a field of a frozen instance raises FrozenInstanceError. Two things follow. A frozen dataclass is hashable, so it can be a dict key or a set member — a useful property for a cache keyed on (model, prompt). And it is safe to share: a function that receives a ModelSpec cannot accidentally change it for everyone else.
Configuration objects want to be frozen. Anything that is a record of a fact — a usage figure, a price at a date — wants to be frozen. Things that change over their life, like a Conversation, do not.
Ordering and other options
@dataclass(order=True) generates <, <= and the rest, comparing field by field in declaration order, so a list of instances sorts without a key. kw_only=True makes every field keyword-only, which prevents the bug where two int fields get swapped by position. slots=True stores fields in a fixed structure instead of a per-instance dict, which halves memory for a million small objects and makes attribute access slightly faster.
To and from dicts
from dataclasses import asdict
json.dumps(asdict(u)) # '{"input_tokens": 2000, ...}'
Usage(**json.loads(text)) # back againasdict recurses into nested dataclasses, lists and dicts. The ** unpacking on the way back works as long as the JSON keys match the field names exactly and nothing extra is present; an unexpected key raises TypeError. That strictness is a feature when reading your own files, and a nuisance when reading someone else's, which is where the next paragraph comes in.
When to reach for pydantic instead
A dataclass trusts its inputs. Usage("2000", None, 42) constructs happily; the types are decoration. When the data comes from outside — a file someone else wrote, a model's reply, a web request — you want validation, and that is pydantic's BaseModel, which you met in module 6. It looks almost identical:
class Usage(BaseModel):
input_tokens: int
output_tokens: int
model: strbut Usage(input_tokens="2000", ...) coerces to 2000, and Usage(input_tokens="lots", ...) raises with a message. The rule: dataclasses for your own internal objects, pydantic at the boundary where untrusted data enters. Do not put pydantic on everything; validation costs time, and inside your own code the types are already right.
NamedTuple is a third option — a tuple with names, immutable, hashable, unpackable — and is the lightest of the three when a function needs to return two or three values with names. TypedDict is a fourth: it is a dict with declared keys, for when the data must stay a dict because a library wants one.
Try this now
Turn the Budget class from module 6 into a dataclass with cap_usd: float, spent: float = 0.0 and calls: int = 0, keeping the charge method. Then make a frozen ModelSpec, put two of them in a set, and try to change one.
The one thing to keep
A dataclass writes __init__, __repr__ and __eq__ from the field list; a mutable default must go through field(default_factory=...), frozen=True makes instances hashable and immutable, and asdict is the road to JSON.
Before you move on
A developer declares `@dataclass class Job: prompts: list = []` and Python refuses to define the class with `ValueError: mutable default <class 'list'> for field prompts is not allowed`. What is the dataclass protecting them from?
Pick the one you would defend. Nobody sees your answer.