Skip to content

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-5 raises FrozenInstanceError. Also makes the instance hashable, so it can go in a set or be a dict key.
  • slots=True — replaces the per-instance __dict__ with a fixed set of attribute slots. Smaller memory, faster attribute access, and attribute typos raise AttributeError instead of silently creating a new attribute. Requires 3.10+.
  • order=True — adds comparison operators. Sorts by field declaration order. Useful for sorted(list_of_results).
  • kw_only=True — every field becomes keyword-only. No positional args. Long configs become more readable: TrainConfig(lr=3e-4), not TrainConfig(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

ArgumentWhat it does
default=XDefault value (alternative to field(...) syntax).
default_factory=callableCalled to produce a default per instance.
init=FalseField isn’t a constructor argument. Use with default or set in __post_init__.
repr=FalseField is excluded from __repr__. Useful for huge tensors or secrets.
compare=FalseField is excluded from __eq__ and ordering.
hash=FalseField is excluded from __hash__.
kw_only=TrueThis 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.”

@dataclass(frozen=True, slots=True)
class EvalResult:
    accuracy: float
    f1: float
    n_samples: int

What this buys you:

  1. Hashable. Can go in a set, can be a dict key. set(results) to dedupe. cache[result] = full_metrics to memoise.
  2. Immutable. No spooky-action-at-a-distance. Functions can’t accidentally mutate a config you passed in.
  3. Smaller and faster. __slots__ drops the __dict__, saving ~50% memory per instance and ~30% on attribute access. Matters when you have many.
  4. Typo detection. result.acuracy = 0.9 raises AttributeError instead 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

dataclassattrspydanticNamedTuple
Stdlibyesnonoyes
Runtime validationnooptionalyesno
Default for ML configsyes(legacy)when validation neededrare
Frozen / hashableoptionaloptionaloptionalalways
Mutability defaultmutablemutablemutableimmutable
Performancefastfastslowest (validation)fastest (it’s a tuple)
Serialization to JSONmanual / dataclasses.asdictyesfirst-classmanual

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) → NamedTuple or tuple.
  • 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=True and __post_init__. You can’t assign to self.x in a frozen dataclass. Use object.__setattr__(self, "x", value) if you really must.
  • Inheritance with mixed defaults. Use kw_only=True to 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, iterate dataclasses.fields(cfg) and build the dict manually.

Where dataclasses show up in real ML codebases

  • Hugging Face TransformersTrainingArguments, ModelOutput base class, every Config is a dataclass.
  • Hydra — your config schema is a dataclass; Hydra parses YAML into it.
  • PyTorch LightningTrainer arguments, callback states.
  • MLflowRunInfo, Metric, Param are dataclass-like records.
  • Internal codebases — every Config, Result, Metadata, BatchOutput is 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 — dataclassesdocs.python.org — canonical reference.
  • PEP 557 — Data Classespeps.python.org — original spec.
  • attrs documentationattrs.org — the predecessor and still excellent.
  • Raymond Hettinger — Dataclasses talkyoutube.com — by the dataclasses author.
  • Fluent Python (2nd ed.) — Chapter 5oreilly.com — Data Class Builders.