2.10 Dataclasses, properties and clean design
Checked against the Python 3 documentation, August 2026
What this is and why it exists
This topic is about the classes you write for yourself: experiment configurations, results, the small structured things that pile up in a project. Dataclasses remove the boilerplate from them, properties let you add validation without changing how anybody calls your code, and both exist to solve one problem — that a project readable today is unreadable in six months if its data has no shape.
The vocabulary
- Dataclass — a class whose fields are declared as annotated attributes, with the initialiser and representation generated for you.
- Field — one of those attributes; the decorator finds them by their type annotation.
- Frozen — a dataclass whose fields cannot be reassigned after creation.
- default_factory — a zero-argument callable producing a fresh default, used for mutable ones.
- Property — an attribute backed by a method, so reading or assigning it can run code.
- Enum — a fixed set of named values, so a typo becomes an error rather than a wrong answer.
- NamedTuple — a tuple whose positions have names.
The mental model
The documentation describes the dataclass decorator as adding "generated special methods such as __init__() and __repr__() to user-defined classes", and it finds the fields by looking for class variables with a type annotation. That is the whole idea: declare what the thing holds, and the plumbing appears. Three annotated lines give you a working initialiser, a readable representation and a sensible equality — the same three things the previous topic had you write by hand.
Two options matter. frozen=True means, in the documentation's words, that "assigning to fields will generate an exception. This emulates read-only frozen instances" — which makes the object hashable and safe to use as a dict key, and makes accidental mutation impossible. Configurations should almost always be frozen: a settings object quietly changed halfway through a run is a result you cannot reproduce.
The other is the mutable-default rule, which by now you should be able to predict. A list written directly as a default would be shared by every instance, exactly as in the functions and classes topics. The documented remedy is a factory: "a zero-argument callable that will be called when a default value is needed for this field", so each instance gets its own. Three appearances of the same rule in one module is not repetition — it is Python telling you something structural about how defaults work.
Properties solve a different problem: adding behaviour to an attribute without changing the interface. Start with a plain attribute; the day you need validation on assignment or a value computed from others, turn it into a property and every existing caller keeps working unchanged. That is worth knowing precisely because it means you should not write getters and setters up front — Python lets you add them later without breaking anybody, so the pre-emptive versions are cost with no benefit.
Enums are for closed sets. A status that can be one of four things wants to be an enum rather than a string, because a misspelled string is a value that silently takes the wrong path while a misspelled enum member is an error at the point you wrote it. And a NamedTuple is a tuple whose positions have names — the documentation calls it a tuple subclass "used to create tuple-like objects that have fields accessible by attribute lookup" — useful for small immutable returns where a dataclass would be heavier than the thing it holds.
Then the trap this topic exists for. The dictionary you reach for "only for now" is the one nobody can read in three weeks. A dict has no declared keys, no defaults, no validation and no representation; three weeks later, nobody remembers which keys exist, whether one is optional, or what type it holds — and the only way to find out is to read every place it is constructed. A frozen dataclass answers all of that in five lines and documents itself. When you are about to write your third nested dict, that is the moment.
Finally, the design principles, briefly and honestly. Keep each class responsible for one thing. Depend on what an object can do rather than on what class it is — which in Python means the duck typing and Protocols of the previous topic. And prefer small pieces you can compose. Those three carry nearly all the value in ordinary work; the rest is worth reading about when you meet a problem it explains.
In code
Checked against the dataclasses and enum references.
from dataclasses import dataclass, field
from enum import Enum
class Status(Enum):
PENDING = "pending"
RUNNING = "running"
DONE = "done"
@dataclass(frozen=True)
class TrainingConfig:
name: str
learning_rate: float = 0.001
epochs: int = 10
tags: list[str] = field(default_factory=list) # never a bare []
@dataclass
class Run:
config: TrainingConfig
status: Status = Status.PENDING
_losses: list[float] = field(default_factory=list)
@property
def best_loss(self):
return min(self._losses) if self._losses else None
def record(self, loss: float):
if loss < 0:
raise ValueError(f"loss cannot be negative: {loss}")
self._losses.append(loss)
config = TrainingConfig(name="baseline", learning_rate=0.01, tags=["first"])
print(config) # a readable representation, generated for you
run = Run(config=config)The configuration is frozen because nothing should change it mid-run; the run is not, because recording losses is its job. best_loss is a property, so callers read it like an attribute while it is computed on demand. And the tags field uses a factory, so two configurations never share a list.
What you should now be able to explain or do
Turn a nested dict into a dataclass and say what you gained. Say what frozen=True prevents and why configurations want it. State the mutable-default rule for the third time and write the factory. Convert an attribute into a property without changing a single caller, and say why that means not writing getters up front. Choose between an enum and a string for a closed set of values, and say what the enum catches.
Check yourself
What does the dataclass decorator generate, and how does it find the fields?
The initialiser, the representation and equality, from class variables that carry a type annotation. Declaring what the thing holds is the whole input.
Why should a configuration be frozen?
Because a settings object changed halfway through a run produces a result you cannot reproduce. Frozen makes assignment an error, and makes the object hashable into the bargain.
A dataclass field defaults to an empty list. What is wrong and what is the fix?
Every instance would share one list. Use a factory that produces a fresh one per instance — the same rule as mutable default arguments and mutable class attributes.
Why not write getters and setters from the start?
Because Python lets you convert a plain attribute into a property later without changing any caller. Writing them up front is cost paid for a flexibility you already had.
When should a value be an enum rather than a string?
When the set of valid values is closed. A misspelled string silently takes the wrong path; a misspelled enum member fails where it was written.
Go deeper
We haven't checked most of these for screen reader use yet.
Back to Dataclasses, properties and clean design: work through the checklist