Dataclasses and Modelling Data
Replacing dictionaries-as-records with types that describe themselves. dataclass, frozen instances, post-init validation, and choosing between NamedTuple, TypedDict and a validation library.
Replacing dictionaries-as-records with types that describe themselves. dataclass, frozen instances, post-init validation, and choosing between NamedTuple, TypedDict and a validation library.
Your program passes dictionaries around. A photo is
{"name": ..., "city": ..., "size": ...}, which was fast to
write and is now the reason for a bad afternoon: somewhere,
something writes "filename" instead of "name", and nothing
notices until a caption comes out empty three steps later.
A dictionary will accept any key you invent and answer honestly that it does not have the one you asked for. By the end of this lesson your data will describe itself — what fields exist, what types they hold, which are required — and your tools will enforce it before the program runs.
photo = {"name": "dawn.jpg", "city": "Lisbon", "size": 1450}
print(photo["nmae"]) # KeyError, at runtime, somewhere else
photo["sixe"] = 2000 # no error at all - a new key appearsDictionaries are the right tool for genuinely dynamic collections — parsed JSON, a tally, anything where the keys are data. They are the wrong tool for a record: a fixed set of named fields that every instance has.
A record dictionary gives you no autocomplete, no type checking, no place to hang behaviour, and a typo creates a new key rather than an error. The last one is the worst, because it fails silently in the direction of "looks fine".
You could hand-write a class, as the foundations course did.
@dataclass writes the tedious parts for you:
from dataclasses import dataclass
@dataclass
class Photo:
name: str
city: str
size: int
caption: str | None = NoneThat is the whole definition, and it generates:
photo = Photo("dawn.jpg", "Lisbon", 1450) # __init__
print(photo) # __repr__
# Photo(name='dawn.jpg', city='Lisbon', size=1450, caption=None)
print(photo == Photo("dawn.jpg", "Lisbon", 1450)) # __eq__ -> TrueCompare the equivalent by hand — an __init__ with four
assignments, a __repr__ with four fields, an __eq__
comparing four attributes — and note that the hand-written
version has four places to forget to update when a field is
added.
The annotations are doing double duty. They are required
(@dataclass reads them to find the fields) and they are the
type hints from the previous lesson, so a checker knows
photo.size is an int and that photo.captoin does not
exist.
Fields with defaults must come after those without, the same rule as function parameters.
The mutable default trap from the foundations course applies here too, and dataclasses turn it from a silent bug into an error:
@dataclass
class Album:
name: str
photos: list[Photo] = []
# ValueError: mutable default <class 'list'> for field photosPython refuses, rather than letting every Album share one
list. The fix:
from dataclasses import dataclass, field
@dataclass
class Album:
name: str
photos: list[Photo] = field(default_factory=list)
tags: set[str] = field(default_factory=set)default_factory takes a function that is called for each new
instance, so each gets its own empty list. This is one of the
few places Python protects you from a mistake it otherwise
allows.
By default a dataclass is mutable. Adding frozen=True makes it
immutable:
Three things follow, and they are the reason to reach for it as the default rather than the exception.
Nothing can change it behind your back. Passing a frozen object to a function is passing a value, not a shared thing that might come back different.
It can be a dictionary key or a set member, because frozen
dataclasses get a __hash__. Mutable ones do not.
Changes become explicit. To alter a frozen object you make a
new one, which replace does neatly:
The habit worth forming: start frozen, and unfreeze when you find a real reason. Most records are read far more than written, and the ones that genuinely need mutation announce themselves.
__post_init__ runs after the generated __init__:
Now an invalid Dimensions cannot exist. That is a stronger
guarantee than checking at the point of use, because it holds
everywhere the object is seen — every function receiving one can
assume it is valid without re-checking.
Note the @property: a computed value that reads like a field.
size.aspect_ratio rather than size.aspect_ratio(), and it is
not stored, so it cannot go stale.
Four things look similar and are not.
dataclass — a record with fields, optionally with
behaviour and validation. The default choice for your own data.
NamedTuple — a record that is also a tuple: immutable,
indexable, unpackable, and slightly lighter.
Use it when the thing genuinely is a small fixed tuple and unpacking is natural. A frozen dataclass is better when you want named access only, because tuple behaviour you did not want is a way for callers to depend on field order.
TypedDict — a dictionary with known keys. It stays a real
dictionary at runtime, so it is the right description for data
that must remain a dict: parsed JSON, an API payload, something
you serialise directly.
A validation library such as pydantic — like a dataclass,
but it checks types at runtime and converts where it can. That
matters only at a boundary, where the data came from outside and
its shape is a hope rather than a fact.
The dividing line is worth stating.
A dataclass.
Inside your own program the type checker catches a wrong value at the line that wrote it, before anything runs.
Paying for runtime validation here buys nothing.
A validation model.
Parsed JSON, an API payload, a config file, a form. There is no checker on the other side of that boundary, and the shape is a hope rather than a fact.
You need something that actually looks.
Bad — a shape that mirrors how the data happens to be stored.
Good — a shape that says what is actually true.
In the first version status is any string at all, so
"complete" and "Done" are accepted and neither matches the
comparison you wrote — a typo becomes a permanently unhandled
state. processed_at as text cannot be compared or sorted
without parsing it first, at every use.
The second makes the wrong status unrepresentable and the date a date. Both changes move a class of bug from runtime to the moment you write the code — which is the whole ambition of this lesson.
Two useful conversions, since data usually needs to leave:
Your data can now describe itself, refuse to be built in an
invalid state, and be checked by a tool before it runs. The
habit to carry is the modelling one: an Enum instead of a
loose string, a datetime instead of text, and validation in
the constructor rather than at each point of use.
Next is Errors as Design, which takes the same instinct and applies it to failure. You have made valid data easy to trust; that lesson is about making failure easy to handle — exception hierarchies callers can catch precisely, and the question of when a function should raise rather than return.
Before you move on, find a dictionary in your code that is really a record and convert it to a frozen dataclass. Then run a type checker and see what it says about the places that used it. In most codebases that exercise finds at least one field access that was never going to work.
DEFINING
@dataclass
class Photo:
name: str annotations ARE the fields
size: int
caption: str | None = None defaults come last
generates __init__, __repr__, __eq__
MUTABLE DEFAULTS
photos: list = [] ValueError - Python refuses
photos: list = field(default_factory=list) the fix
FROZEN - prefer it
@dataclass(frozen=True)
immutable, hashable, usable as a dict key
replace(obj, width=3840) a changed copy
VALIDATION AND COMPUTED VALUES
def __post_init__(self): runs after __init__; raise here
@property computed, reads like a field
CHOOSING
dataclass your own records <- the default
NamedTuple genuinely a small tuple; unpacks
TypedDict must stay a real dict (JSON, payloads)
pydantic etc. data that ARRIVED - checks at runtime
created by you -> dataclass
arrived from outside -> validate it
MODELLING
Enum instead of a string with four legal values
datetime instead of a date as text
make invalid states impossible to construct
CONVERTING OUT
asdict(obj) astuple(obj)@dataclass(frozen=True)
class Dimensions:
width: int
height: int
size = Dimensions(1920, 1080)
size.width = 800
# FrozenInstanceError: cannot assign to field 'width'from dataclasses import replace
bigger = replace(size, width=3840) # a new Dimensions@dataclass(frozen=True)
class Dimensions:
width: int
height: int
def __post_init__(self):
if self.width <= 0 or self.height <= 0:
raise ValueError(
f"dimensions must be positive, got "
f"{self.width}x{self.height}"
)
@property
def aspect_ratio(self) -> float:
return self.width / self.heightfrom typing import NamedTuple
class Dimensions(NamedTuple):
width: int
height: int
w, h = Dimensions(1920, 1080) # unpacks, because it is a tuplefrom typing import TypedDict
class PhotoJSON(TypedDict):
name: str
size: intfrom pydantic import BaseModel
class Config(BaseModel):
output_folder: str
limit: int = 100
config = Config.model_validate(json.loads(raw)) # raises if wrong@dataclass
class Photo:
name: str
status: str # "pending" | "done" | "failed"?
processed_at: str | None # a date, as text
error: str | Nonefrom datetime import datetime
from enum import Enum
class Status(Enum):
PENDING = "pending"
DONE = "done"
FAILED = "failed"
@dataclass(frozen=True)
class Photo:
name: str
status: Status
processed_at: datetime | None = None
error: str | None = Nonefrom dataclasses import asdict, astuple
asdict(photo) # nested dicts - ready for json.dumps
astuple(photo) # a plain tuple