Dataclasses
Boilerplate-free record types — perfect for model configs, training results, and any DTO.
TL;DR
A @dataclass is a class decorator that auto-generates __init__,
__repr__, and __eq__ from your field annotations. Three lines of
declaration replace the twenty-line “init takes these args, store them
all, write a __repr__” boilerplate every codebase used to be full of.
For ML, dataclasses are the default container shape for: training
configs, hyperparameter sets, evaluation results, model metadata,
batch wrappers, anything that’s “a record with fields.” They give you
type checking via the annotations, free __repr__ for logging, and
optionally immutability via frozen=True.
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class TrainConfig:
learning_rate: float = 1e-3
batch_size: int = 32
epochs: int = 10
seed: int = 42
cfg = TrainConfig(learning_rate=3e-4, batch_size=64)
The frozen=True, slots=True combination is the one I reach for nine
times out of ten. Frozen prevents accidental mutation. Slots removes
the per-instance __dict__ and makes the object smaller and faster.
The picture in your head
A dataclass is a one-line schema. You declare what fields exist and what types they have; Python writes the constructor. Compare:
# Before — every project's first 20 lines
class TrainConfig:
def __init__(self, learning_rate=1e-3, batch_size=32, epochs=10, seed=42):
self.learning_rate = learning_rate
self.batch_size = batch_size
self.epochs = epochs
self.seed = seed
def __repr__(self):
return (f"TrainConfig(learning_rate={self.learning_rate}, "
f"batch_size={self.batch_size}, epochs={self.epochs}, "
f"seed={self.seed})")
def __eq__(self, other):
if not isinstance(other, TrainConfig):
return NotImplemented
return (self.learning_rate == other.learning_rate
and self.batch_size == other.batch_size
and self.epochs == other.epochs
and self.seed == other.seed)
vs.
# After — same behaviour, fewer ways to be wrong
@dataclass
class TrainConfig:
learning_rate: float = 1e-3
batch_size: int = 32
epochs: int = 10
seed: int = 42
You don’t get to forget a field in __repr__. You don’t get to hand-write
a buggy __eq__. The decorator generates them from the annotations.
The decorator’s parameters
@dataclass(
init=True, # generate __init__ (default True)
repr=True, # generate __repr__
eq=True, # generate __eq__
order=False, # generate __lt__, __le__, __gt__, __ge__
frozen=False, # make instances immutable
slots=False, # use __slots__ instead of __dict__ (3.10+)
kw_only=False, # all fields are keyword-only (3.10+)
)
The four you’ll actually flip:
frozen=True— instances are immutable.cfg.learning_rate = 1e-5raisesFrozenInstanceError. Also makes the instance hashable, so it can go in asetor be adictkey.slots=True— replaces the per-instance__dict__with a fixed set of attribute slots. Smaller memory, faster attribute access, and attribute typos raiseAttributeErrorinstead of silently creating a new attribute. Requires 3.10+.order=True— adds comparison operators. Sorts by field declaration order. Useful forsorted(list_of_results).kw_only=True— every field becomes keyword-only. No positional args. Long configs become more readable:TrainConfig(lr=3e-4), notTrainConfig(3e-4).
Mutable defaults — field(default_factory=...)
The same mutable-default trap from PY 101 exists for dataclasses, but Python is helpful: it raises an error instead of silently sharing.
from dataclasses import dataclass, field
@dataclass
class Run:
metrics: list[float] = [] # ValueError at class-definition time
@dataclass
class Run:
metrics: list[float] = field(default_factory=list) # correct
default_factory is called each time a new instance is created — fresh
list per instance, no shared state.
@dataclass
class ModelConfig:
layers: list[int] = field(default_factory=lambda: [64, 64])
name: str = "default"
For tuples and other immutables, plain defaults are fine:
@dataclass
class Shape:
dims: tuple[int, ...] = (1, 28, 28)
field(...) — per-field configuration
| Argument | What it does |
|---|---|
default=X | Default value (alternative to field(...) syntax). |
default_factory=callable | Called to produce a default per instance. |
init=False | Field isn’t a constructor argument. Use with default or set in __post_init__. |
repr=False | Field is excluded from __repr__. Useful for huge tensors or secrets. |
compare=False | Field is excluded from __eq__ and ordering. |
hash=False | Field is excluded from __hash__. |
kw_only=True | This specific field is keyword-only. |
metadata={...} | Arbitrary dict — used by libraries like marshmallow. |
@dataclass(frozen=True)
class Run:
name: str
config: TrainConfig
state_dict: dict = field(repr=False, compare=False) # don't print weights
__post_init__ — derived fields
Sometimes you want a field that’s computed from others. __post_init__
runs at the end of the generated __init__:
@dataclass
class TrainConfig:
epochs: int
batch_size: int
samples_per_epoch: int
total_steps: int = field(init=False)
def __post_init__(self):
self.total_steps = self.epochs * (self.samples_per_epoch // self.batch_size)
Don’t reach for __post_init__ when a @property would do — properties
recompute on access (no risk of staleness if other fields change). Use
__post_init__ for “compute once, store, save the cost.”
Frozen + slots — the recommended default
@dataclass(frozen=True, slots=True)
class EvalResult:
accuracy: float
f1: float
n_samples: int
What this buys you:
- Hashable. Can go in a
set, can be adictkey.set(results)to dedupe.cache[result] = full_metricsto memoise. - Immutable. No spooky-action-at-a-distance. Functions can’t accidentally mutate a config you passed in.
- Smaller and faster.
__slots__drops the__dict__, saving ~50% memory per instance and ~30% on attribute access. Matters when you have many. - Typo detection.
result.acuracy = 0.9raisesAttributeErrorinstead of silently creating a new attribute that nothing reads.
The downside of frozen: you can’t just cfg.lr = new_lr. The pattern
is dataclasses.replace(cfg, lr=new_lr), which returns a new instance
with one field changed. This is a feature — explicit “make a new
config” rather than implicit mutation.
from dataclasses import replace
cfg2 = replace(cfg, learning_rate=1e-4, batch_size=128)
Inheritance — careful
Dataclasses can inherit, and the subclass gets a merged __init__.
The catch: fields without defaults can’t come after fields with
defaults (else __init__ is invalid Python). This makes inheritance
awkward when you mix.
@dataclass
class Base:
name: str = "default"
@dataclass
class Sub(Base):
epochs: int # ERROR: non-default after default
Two fixes: kw_only=True (3.10+) on the base, the sub, or both, which
side-steps the positional-arg ordering rule:
@dataclass(kw_only=True)
class Base:
name: str = "default"
@dataclass(kw_only=True)
class Sub(Base):
epochs: int # fine; everything is keyword-only
Or compose instead of inherit. Composition is usually the cleaner option for ML configs.
dataclasses vs attrs vs pydantic vs NamedTuple
| dataclass | attrs | pydantic | NamedTuple | |
|---|---|---|---|---|
| Stdlib | yes | no | no | yes |
| Runtime validation | no | optional | yes | no |
| Default for ML configs | yes | (legacy) | when validation needed | rare |
| Frozen / hashable | optional | optional | optional | always |
| Mutability default | mutable | mutable | mutable | immutable |
| Performance | fast | fast | slowest (validation) | fastest (it’s a tuple) |
| Serialization to JSON | manual / dataclasses.asdict | yes | first-class | manual |
Decision tree:
- Internal config / result types →
@dataclass(frozen=True, slots=True). - Anything from a network boundary (API requests, YAML configs from disk, LLM JSON outputs) → Pydantic. See PY 109.
- Tiny fixed-shape thing where you want positional unpacking (e.g.
(x, y) = pos) →NamedTupleortuple. - Existing codebase already uses
attrs→ keep it. It predates dataclasses and is still excellent.
The most common mistake is reaching for Pydantic for everything. Pydantic validates on every construction, which is overhead you don’t want for internal data flowing between functions. Use dataclasses internally and Pydantic at the boundaries.
A worked ML example — config, run, result
from dataclasses import dataclass, field, replace
from datetime import datetime, UTC
from pathlib import Path
@dataclass(frozen=True, slots=True, kw_only=True)
class TrainConfig:
"""All hyperparameters. Frozen so it can't be mutated mid-training."""
learning_rate: float = 3e-4
batch_size: int = 32
epochs: int = 10
seed: int = 42
layers: tuple[int, ...] = (64, 64)
@dataclass(frozen=True, slots=True, kw_only=True)
class RunMetadata:
name: str
started_at: datetime = field(default_factory=lambda: datetime.now(UTC))
git_sha: str | None = None
output_dir: Path = field(default_factory=lambda: Path("runs"))
@dataclass(frozen=True, slots=True, order=True, kw_only=True)
class EvalResult:
"""Frozen + ordered — sortable by field order (loss first)."""
loss: float
accuracy: float
n_samples: int = field(compare=False) # don't sort by sample count
cfg = TrainConfig(learning_rate=1e-4, batch_size=64)
meta = RunMetadata(name="baseline-v3")
# Tweak the config — get a new one back, original unchanged
cfg_lower_lr = replace(cfg, learning_rate=1e-5)
# Sort eval results by loss (best first) thanks to order=True
sorted_results = sorted(all_results, reverse=False)
Every line of boilerplate is gone. Every field has a type. Nothing’s mutable that shouldn’t be. The code reads like a schema.
Common gotchas
- Mutable default error.
field(default_factory=list)for any mutable. Python won’t let you forget — it raises at class-definition time. frozen=Trueand__post_init__. You can’t assign toself.xin a frozen dataclass. Useobject.__setattr__(self, "x", value)if you really must.- Inheritance with mixed defaults. Use
kw_only=Trueto side-step the ordering rule, or compose instead. - Slots and pickling. Slotted dataclasses pickle fine in 3.10+. Older Python had issues; not relevant for new code.
asdict()is a deep conversion.dataclasses.asdict(cfg)recurses into nested dataclasses and converts to dicts. For one-level conversion, iteratedataclasses.fields(cfg)and build the dict manually.
Where dataclasses show up in real ML codebases
- Hugging Face Transformers —
TrainingArguments,ModelOutputbase class, everyConfigis a dataclass. - Hydra — your config schema is a dataclass; Hydra parses YAML into it.
- PyTorch Lightning —
Trainerarguments, callback states. - MLflow —
RunInfo,Metric,Paramare dataclass-like records. - Internal codebases — every
Config,Result,Metadata,BatchOutputis a dataclass.
The defensive habit: when you write a class with only __init__ and
no methods, stop. It should be a @dataclass. When you write a class
with only __init__ and a few computed properties, stop. It should
be a frozen @dataclass with @propertys.
Resources
- Python docs — dataclasses — docs.python.org — canonical reference.
- PEP 557 — Data Classes — peps.python.org — original spec.
- attrs documentation — attrs.org — the predecessor and still excellent.
- Raymond Hettinger — Dataclasses talk — youtube.com — by the dataclasses author.
- Fluent Python (2nd ed.) — Chapter 5 — oreilly.com — Data Class Builders.