Why dataclasses exist #
Before Python 3.7, writing a small class to hold structured data meant writing a lot of boilerplate: an __init__ to assign each attribute, an __eq__ to compare instances field by field, and a __repr__ so instances print something useful. None of that logic is interesting — it’s mechanical, and mechanical code is exactly where bugs and inconsistencies creep in, like forgetting to update __eq__ after adding a field, for example.
The dataclasses module, introduced in Python 3.7 and originally described in PEP 557, solves this with a class decorator, @dataclass, that generates automatically these “dunder” methods for you based on the class’s type-annotated fields. As described in the official documentation, the module adds generated special methods — like __init__() and __repr__() — to user-defined classes, and the fields it works from are identified using the type-annotation syntax from PEP 526.
In short: dataclasses let you describe what data a class holds, and Python writes the boilerplate for you.
The canonical example #
Here is the example from the official Python documentation:
from dataclasses import dataclass
@dataclass
class InventoryItem:
"""Class for keeping track of an item in inventory."""
name: str
unit_price: float
quantity_on_hand: int = 0
def total_cost(self) -> float:
return self.unit_price * self.quantity_on_handThis automatically generates an __init__ equivalent to:
def __init__(self, name: str, unit_price: float, quantity_on_hand: int = 0):
self.name = name
self.unit_price = unit_price
self.quantity_on_hand = quantity_on_handYou didn’t write that __init__ — it is automatically added to the class: it is not directly specified in the InventoryItem definition. You also get a readable __repr__ and a working __eq__ for free. Using the class is immediate:
item = InventoryItem("widget", 3.0, 10)
print(item) # InventoryItem(name='widget', unit_price=3.0, quantity_on_hand=10)
print(item.total_cost()) # 30.0
print(item == InventoryItem("widget", 3.0, 10)) # TrueHow @dataclass decides what’s a field
#
The decorator scans the class body for class variables that carry a type annotation. Each one becomes a field, and the order fields appear in the class definition is the order they appear everywhere else — the generated __init__ signature, the __repr__ output, and comparisons.
@dataclass
class C:
a: int # 'a' has no default value
b: int = 0 # 'b' has a default valueThis generates __init__(self, a: int, b: int = 0). One rule to remember: a field without a default cannot follow a field that has one — Python (correctly) raises TypeError if you try, whether the conflict happens within one class or arises through inheritance.
The parameters to @dataclass
#
@dataclass can be used bare (@dataclass) or called with keyword arguments (@dataclass(...)); both forms fall back to the same defaults, so @dataclass and @dataclass() are equivalent. The main options:
| Parameter | Default | Effect |
|---|---|---|
init |
True |
Generate __init__. Skipped if the class already defines one. |
repr |
True |
Generate __repr__, e.g. InventoryItem(name='widget', unit_price=3.0, quantity_on_hand=10). |
eq |
True |
Generate __eq__, comparing fields in order (both objects must be of the identical type). |
order |
False |
Generate __lt__, __le__, __gt__, __ge__, comparing the class as if it were a tuple of its fields. Raises ValueError if order=True but eq=False. |
unsafe_hash |
False |
Forces generation of __hash__ even when it might not be safe to do so. |
frozen |
False |
Makes attribute assignment raise an exception after __init__, emulating immutability. |
match_args |
True |
Generates __match_args__ for use with match statements (added in 3.10). |
kw_only |
False |
Marks all fields as keyword-only in the generated __init__ (added in 3.10). |
slots |
False |
Generates __slots__ and returns a new class (added in 3.10). |
weakref_slot |
False |
Adds a __weakref__ slot; requires slots=True (added in 3.11). |
A subtlety worth knowing: as of Python 3.13, the generated __eq__ method now compares each field individually rather than comparing tuples of fields as in previous versions. This is faster, but it can change results for values that compare equal by identity but not by value, such as float('nan').
Default values that need to be computed: field()
#
Ordinary defaults (b: int = 0) work fine for immutable values, but you cannot write mylist: list = [] directly — dataclasses forbids it. The reason is explained clearly in the docs: default values are stored as class attributes, so a naive implementation would mean every instance shares the same list, exactly the classic “mutable default argument” trap. Since dataclasses can’t generally detect mutability, it uses hashability as an approximation and will raise a ValueError if it detects an unhashable default parameter — lists, dicts and sets are all unhashable, so they’re rejected outright.
The fix is dataclasses.field() with default_factory, a zero-argument callable invoked fresh for each new instance:
from dataclasses import dataclass, field
@dataclass
class D:
x: list = field(default_factory=list)
d1 = D()
d2 = D()
assert d1.x is not d2.x # each instance gets its own listfield() also lets you customize a field beyond just its default value. Useful options:
default/default_factory— a static default, or a callable that produces one (mutually exclusive).init— whether the field appears in the generated__init__(defaultTrue).repr— whether the field appears in__repr__output (defaultTrue).compare— whether the field participates in__eq__/ordering (defaultTrue).hash— override whether the field is used for hashing.kw_only— mark just this one field as keyword-only.metadata— an arbitrary read-only mapping, ignored by dataclasses itself but useful for third-party tools.doc— an optional docstring for the field (added in Python 3.14).
@dataclass
class Employee:
name: str
salary: float = field(repr=False) # hide from repr, e.g. sensitive data
employee_id: int = field(compare=False, default=0) # ignored in equality checks__post_init__: logic after the generated __init__
#
Sometimes a field’s value depends on other fields, or you need validation. If you define __post_init__, the generated __init__ calls it automatically as its last step:
@dataclass
class Rectangle:
width: float
height: float
area: float = field(init=False)
def __post_init__(self):
self.area = self.width * self.heightr = Rectangle(3, 4)
print(r.area) # 12ClassVar and InitVar: fields that aren’t quite fields
#
Two typing constructs change how @dataclass treats an annotated name — these are the only two places the decorator actually inspects the annotation type rather than just noting that one exists:
typing.ClassVarmarks a genuine class-level variable, not a per-instance field. It’s excluded from__init__,__repr__, andfields()entirely.dataclasses.InitVarmarks a value that should be accepted by__init__(and passed to__post_init__) but never stored as an actual field.
from dataclasses import dataclass, InitVar
from typing import ClassVar
@dataclass
class C:
i: int
j: int | None = None
database: InitVar[object | None] = None
total_created: ClassVar[int] = 0
def __post_init__(self, database):
if self.j is None and database is not None:
self.j = database.lookup("j")InitVar is handy for constructor-only parameters — like a database handle used to look up a default — that shouldn’t be treated as part of the object’s actual state.
Frozen (immutable-ish) instances #
Passing frozen=True makes attribute assignment raise dataclasses.FrozenInstanceError after construction:
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: float
y: float
p = Point(1.0, 2.0)
p.x = 5.0 # raises dataclasses.FrozenInstanceErrorThe docs are candid that this is emulation rather than a hard guarantee: it is not possible to create truly immutable Python objects, but frozen=True gets you close enough for most purposes, at the cost of a small performance penalty (assignment inside __init__ has to go through object.__setattr__ rather than plain attribute assignment).
Frozen dataclasses also interact with hashing: if both eq and frozen are true, a __hash__ is generated automatically, making instances usable as dict keys or set members.
Ordering and keyword-only fields #
Setting order=True gives you comparison operators that treat the instance like a tuple of its fields, useful for sorting:
from dataclasses import dataclass, field
@dataclass(order=True)
class Version:
major: int
minor: int
patch: int
versions = [Version(1, 2, 0), Version(1, 0, 5), Version(2, 0, 0)]
print(sorted(versions))Since Python 3.10, you can force some or all fields to be keyword-only, which is a good way to avoid ambiguous positional calls in classes with many fields:
from dataclasses import dataclass, KW_ONLY
@dataclass
class Point:
x: float
_: KW_ONLY
y: float
z: float
p = Point(0, y=1.5, z=2.0) # y and z MUST be passed by keywordInheritance #
When a dataclass subclasses another dataclass, @dataclass walks the MRO and merges fields from base classes with the subclass’s own fields, in definition order — a subclass re-declaring a base field’s name overrides its type/default but keeps its original position:
from typing import Any
@dataclass
class Base:
x: Any = 15.0
y: int = 0
@dataclass
class C(Base):
z: int = 10
x: int = 15The resulting field order is x, y, z, with x now typed as int, giving __init__(self, x: int = 15, y: int = 0, z: int = 10).
Useful module-level functions #
The dataclasses module also ships a handful of standalone helpers:
from dataclasses import dataclass, field, fields, asdict, astuple, replace, is_dataclass
@dataclass
class Point:
x: int
y: int
p = Point(10, 20)
fields(p) # tuple of Field objects describing x and y
asdict(p) # {'x': 10, 'y': 20} (recurses into nested dataclasses/lists/dicts)
astuple(p) # (10, 20)
replace(p, x=99) # Point(x=99, y=20) — a new instance with one field changed
is_dataclass(p) # Trueasdict() and astuple() are especially handy for serialization (e.g. feeding a dataclass into json.dumps after calling asdict), and they recurse into nested dataclasses, lists, dicts, and tuples automatically.
replace() is worth calling out: it doesn’t just copy attributes — it constructs a genuinely new instance by calling __init__() again with the merged values, which means __post_init__ reruns too. That matters if your class does validation or derived-field computation in __post_init__.
make_dataclass: building a dataclass dynamically
#
If field names aren’t known until runtime, make_dataclass builds one programmatically:
from dataclasses import make_dataclass, field
C = make_dataclass(
"C",
[("x", int), "y", ("z", int, field(default=5))],
namespace={"add_one": lambda self: self.x + 1},
)This is equivalent to writing the class out by hand with @dataclass, and is mainly useful for tooling that generates classes from schemas, config files, or database tables.
Descriptor-typed fields (advanced) #
If you assign a descriptor object as a field’s default, @dataclass respects __get__/__set__ semantics rather than treating the descriptor as a plain default value — this lets you build fields with validation or type coercion baked in:
class IntConversionDescriptor:
def __init__(self, *, default):
self._default = default
def __set_name__(self, owner, name):
self._name = "_" + name
def __get__(self, obj, type):
if obj is None:
return self._default
return getattr(obj, self._name, self._default)
def __set__(self, obj, value):
setattr(obj, self._name, int(value))
@dataclass
class InventoryItem:
quantity_on_hand: IntConversionDescriptor = IntConversionDescriptor(default=100)
i = InventoryItem()
print(i.quantity_on_hand) # 100
i.quantity_on_hand = 2.5 # goes through __set__
print(i.quantity_on_hand) # 2This is a niche feature, but it’s worth knowing it exists if you ever see a dataclass field that seems to “coerce” its input.
Common pitfalls, summarized #
- Mutable defaults (
x: list = []) are rejected — usefield(default_factory=list)instead. - Field order with defaults — once a field has a default, every field after it must also have one (unless it’s keyword-only).
__eq__requires identical types — a dataclass instance never equals an instance of an unrelated class, even with matching field values.frozen=Trueisn’t true immutability — it blocks attribute assignment, not deeper mutation of mutable fields you store (e.g. a list field’s contents can still be appended to).ClassVarandInitVarfields are excluded fromfields()— don’t expectasdict()orfields()to include them.
Where to go next #
- Official reference:
dataclasses— Data Classes - Design rationale: PEP 557 – Data Classes
- Type-annotation rules dataclasses are built on: PEP 526 – Syntax for Variable Annotations
- If you’re choosing between dataclasses,
NamedTuple, and plain classes, the standard library docs’ introduction and the PEP’s rationale section both cover the trade-offs in more depth than fits here.