Skip to main content
  1. Posts/

A Practical Tutorial on Dataclasses in Python

··1945 words·10 mins·

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_hand

This 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_hand

You 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))  # True

How @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 value

This 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 list

field() 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__ (default True).
  • repr — whether the field appears in __repr__ output (default True).
  • compare — whether the field participates in __eq__/ordering (default True).
  • 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.height
r = Rectangle(3, 4)
print(r.area)  # 12

ClassVar 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.ClassVar marks a genuine class-level variable, not a per-instance field. It’s excluded from __init__, __repr__, and fields() entirely.
  • dataclasses.InitVar marks 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.FrozenInstanceError

The 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 keyword

Inheritance
#

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 = 15

The 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)       # True

asdict() 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)   # 2

This 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
#

  1. Mutable defaults (x: list = []) are rejected — use field(default_factory=list) instead.
  2. Field order with defaults — once a field has a default, every field after it must also have one (unless it’s keyword-only).
  3. __eq__ requires identical types — a dataclass instance never equals an instance of an unrelated class, even with matching field values.
  4. frozen=True isn’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).
  5. ClassVar and InitVar fields are excluded from fields() — don’t expect asdict() or fields() to include them.

Where to go next
#

Angelo Varlotta
Author
Angelo Varlotta
If you can’t explain it simply, you don’t understand it well enough – Albert Einstein