Python Data Classes: Cleaner Code with Less Boilerplate
What Are Data Classes?
The dataclass decorator (introduced in Python 3.7 via the standard-library dataclasses module) is a way to declare classes whose primary purpose is to store data. You write annotations for the fields you want, and the decorator generates the boring parts for you: __init__, __repr__, __eq__, and others.
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
That single block is equivalent to a plain class that manually writes out an __init__ taking x and y, a __repr__ showing Point(x=..., y=...), and an __eq__ comparing both fields. The decorator does the typing and the ceremony so you do not have to.
Why Reach for Them?
There is nothing you can do with a data class that you cannot do by hand — the value is in what you get rid of.
vs plain classes. A classic class Point: with a hand-written __init__, __repr__, and __eq__ is easy to get slightly wrong (forgetting a field in __eq__, swapping order in __init__). Data classes keep the definition and the generated methods in sync automatically.
vs tuples. Tuples are compact and fast, but they are positional. p[0] is opaque until you remember it is x. A data class reads p.x and still lets you unpack x, y = p when you need to.
vs dicts. A dict like {"x": 1.0, "y": 2.0} is flexible, but it has no type hints, no validation, and no attribute access. A data class gives you point.x plus static typing without losing the lightweight feel.
The trade-off is worth it whenever a type is mostly "a bag of named, typed values" — configuration, records, DTOs, results returned from a function, rows mapped from a database.
The @dataclass Decorator
Decorating a class with @dataclass inspects its annotations and, by default, generates __init__, __repr__, and __eq__. The fields are inferred from annotated class attributes:
from dataclasses import dataclass
@dataclass
class InventoryItem:
name: str
quantity: int
price: float
You do not need to import field here; plain annotations on str, int, float are enough.
What Gets Generated
dataclass() accepts parameters that toggle which dunder methods it synthesizes. The common ones:
init=True(default) — generates__init__(self, name, quantity, price). Passinit=Falsefor a field that should never be set by the constructor.repr=True(default) — generates__repr__showing each field by name. Handy for debugging and logging.eq=True(default) — generates__eq__comparing tuples of fields. TwoInventoryIteminstances are equal iffname,quantity, andpriceall match.frozen=False(default) — whenTrue, generates__setattr__/__delattr__that raiseFrozenInstanceError. This makes instances hashable and immutable, so they become safe to use as dict keys or set members. Note that a plain (non-frozen) data class is unhashable by default, mirroring a normal mutable class.order=True— generates__lt__,__le__,__gt__,__ge__ordering the instance by a tuple of its fields. Useful when you need tosort()ormin()/max()them. Implieseq=Trueand a consistent field order, so the fields must be orderable among themselves.
from dataclasses import dataclass
@dataclass(order=True)
class Score:
player: str
points: int
scores = [Score("Ada", 7), Score("Linus", 10), Score("Grace", 7)]
for s in sorted(scores):
print(s)
order here sorts on player first, then points. Be deliberate about field order when you use it.
Default Values
You can give any field a default value, and the generated __init__ will use it when the caller omits the argument:
from dataclasses import dataclass, field
@dataclass
class Config:
host: str = "localhost"
port: int = 8080
flags: list[str] = field(default_factory=list)
Rules to remember:
- Mutable default values (a bare
list,dict, or a custom object) are shared across instances, which is almost never what you want.dataclassesraisesValueErrorif you tryflags: list = []to protect you. - For mutable defaults, use
field(default_factory=...). A factory is called once per instance with no arguments, sodefault_factory=listgives each instance its own list. This also works for your own types:default_factory=MyConfig.from_env. - Fields with defaults must come after fields without defaults, just like regular function parameters.
dataclassesenforces this and raisesTypeErrorif you get it wrong.
Field Control with field()
Not every attribute should be a constructor parameter or part of the generated repr. field() lets you opt out:
from dataclasses import dataclass, field
@dataclass
class Session:
user: str
token: str = field(repr=False, compare=True)
created_at: float = field(default_factory=lambda: __import__("time").time())
Key field() arguments:
default/default_factory— the value sources described above.repr=False— hides a field from the generated__repr__(good for secrets or noisy values).compare=False— excludes a field from__eq__/__ne__/ordercomparisons.init=False— the field exists on the instance but is not accepted by__init__; you typically set it insidefield(default=...)or in__post_init__.init=False, default=...— a way to attach a class-level constant that is the same for every instance but excluded from the constructor signature.
__post_init__
__post_init__ runs once, right after the generated __init__ finishes assigning each field. It is the intended place to compute derived values, validate, or normalize:
from dataclasses import dataclass, field
@dataclass
class Temperature:
celsius: float
_kelvin: float = field(init=False, repr=False)
def __post_init__(self) -> None:
if self.celsius < -273.15:
raise ValueError("temperature below absolute zero")
self._kelvin = self.celsius + 273.15
Here _kelvin is excluded from __init__ (init=False) and from the repr, then populated in __post_init__ from celsius. The public constructor Temperature(celsius=...) still behaves like a normal parameterised class.
Inheritance
Data classes inherit normally, with the usual caveat that field order matters. A base data class that defines some fields can be subclassed, and fields from the parent come first in the generated __init__:
from dataclasses import dataclass
@dataclass
class Contact:
name: str
email: str = ""
@dataclass
class Customer(Contact):
account_id: str = ""
balance: float = 0.0
Customer("Ada", "ada@example.com", "C-123", 250.0) works because the parameter list is (name, email, account_id, balance) — parent fields first, then child fields. The one thing to avoid is redefining a field that exists in the base class; that changes the slot ordering and raises TypeError, so extend rather than override.
A Practical Example
Putting it together: a tiny inventory model where items arrive from JSON, are compared and sorted, and cached behind an immutable snapshot.
from __future__ import annotations
from dataclasses import dataclass, field, asdict
from typing import Any
@dataclass
class Product:
sku: str
name: str
quantity: int = 0
price: float = 0.0
tags: list[str] = field(default_factory=list)
# runs after __init__ assigns every field above
def __post_init__(self) -> None:
if self.price < 0:
raise ValueError("price must be non-negative")
self.sku = self.sku.upper()
# convenience: build one Product from a raw dict
@classmethod
def from_row(cls, row: dict[str, Any]) -> Product:
return cls(
sku=row.get("sku", ""),
name=row.get("name", ""),
quantity=int(row.get("quantity", 0)),
price=float(row.get("price", 0.0)),
tags=list(row.get("tags", [])),
)
@dataclass(order=True, frozen=True)
class Snapshot:
# `order=True` lets us sort snapshots by total value.
# the first field below acts as the sort key via the `field` wrapper:
total_value: float = field(compare=True)
products: tuple[Product, ...] = field(compare=False, default=())
# ---- simulate rows from a database or CSV -----
rows = [
{"sku": "abc", "name": "Widget", "quantity": 3, "price": "4.50", "tags": ["tool", "new"]},
{"sku": "XYZ", "name": "Gadget", "quantity": 2, "price": "12.00", "tags": ["tool"]},
{"sku": "def", "name": "Widget Pro", "quantity": 1, "price": "19.99", "tags": ["new"]},
]
products = [Product.from_row(r) for r in rows]
print(products[0])
# Product(sku='ABC', name='Widget', quantity=3, price=4.5, tags=['tool', 'new'])
# sort by unit price
for p in sorted(products, key=lambda x: x.price):
print(p.name, p.price)
# an immutable, hashable snapshot we could use as a dict key
snap = Snapshot(
total_value=sum(p.quantity * p.price for p in products),
products=tuple(sorted(products, key=lambda x: x.sku)),
)
print(asdict(snap))
The Product class is mutable and comparison-friendly (so sort, ==, and __repr__ work out of the box), while Snapshot is frozen and ordered — the right shape for a value you store, deduplicate, or pass between threads without worrying about it changing underneath you.
When to Stop Using Them
Data classes are not a replacement for every class. If your object owns expensive resources, has invariants you must protect, or needs __slots__ for memory savings across millions of instances, a hand-rolled class is still the right tool. Reach for @dataclass when the question you are answering is "what data lives here, and how should it compare?", not "how do I hide state behind an abstraction?".