@dataclass generates methods such as __init__, __repr__, and __eq__ from annotated fields. It works well for objects that hold data with modest behavior, but it should not turn every class into an automatic model.

Build a safe data model

from dataclasses import dataclass, field


@dataclass(frozen=True, slots=True)
class Order:
    code: str
    items: tuple[str, ...] = field(default_factory=tuple)

    def __post_init__(self) -> None:
        if not self.code.strip():
            raise ValueError("empty code")


order = Order("A-42", ("book", "course"))
print(order)

The decorator inspects annotated attributes and builds an initializer in field order. Required fields must precede fields with defaults, including across inheritance. Keyword-only fields can make configuration calls safer:

from dataclasses import dataclass


@dataclass(kw_only=True)
class ConnectionOptions:
    host: str
    port: int = 5432
    timeout: float = 5.0


options = ConnectionOptions(host="db.internal", timeout=2.5)

This prevents an innocent field reordering from silently changing positional arguments. It is particularly useful when several parameters have the same type.

Equality, ordering, and hashing

Two instances compare equal by default when they have the same class and all comparison-enabled fields contain equal values. This is value equality, not identity. order=True generates ordering methods and compares fields like a tuple in declaration order.

from dataclasses import dataclass, field


@dataclass(order=True)
class Task:
    priority: int
    title: str = field(compare=False)


tasks = [Task(3, "publish"), Task(1, "review")]
print(sorted(tasks))

Excluding title is a domain decision: tasks with equal priorities now compare equal despite different titles. Do not add compare=False merely to satisfy a test. Decide which fields define the value.

Hashing requires similar care. A frozen dataclass can usually receive a generated __hash__, while a mutable one normally remains unhashable. This prevents a dictionary key from changing after insertion. unsafe_hash=True serves specialized cases, but its name warns that mutating a compared field can break lookups.

Validate and derive fields

__post_init__ runs after the generated initializer. It can validate relationships or calculate a field declared with init=False.

from dataclasses import dataclass, field
from decimal import Decimal


@dataclass(frozen=True)
class LineItem:
    quantity: int
    unit_price: Decimal
    total: Decimal = field(init=False)

    def __post_init__(self) -> None:
        if self.quantity <= 0:
            raise ValueError("quantity must be positive")
        if self.unit_price < 0:
            raise ValueError("unit price cannot be negative")
        object.__setattr__(
            self, "total", self.unit_price * self.quantity
        )

object.__setattr__ is necessary because the frozen guard already applies. Use this escape hatch only while constructing a derived value. If initialization needs network access, database queries, or many recovery paths, prefer a named factory. Deterministic constructors are easier to test.

InitVar accepts a value in __init__ and forwards it to __post_init__ without storing it as a field. It works for a small normalization input, although an explicit factory usually communicates a complex conversion better.

Copy and convert deliberately

dataclasses.replace(instance, field=value) creates another instance and invokes initialization again. It offers a convenient update style for frozen values:

from dataclasses import replace

discounted = replace(order, code="A-42-SALE")

asdict() recursively converts nested dataclasses and deep-copies other values. It is convenient for small boundaries, but can be expensive and is not a complete JSON serializer. Dates, decimals, enums, and custom objects still require an explicit representation. For a shallow mapping, select fields intentionally or iterate over dataclasses.fields().

Dataclasses support inheritance, but composition is often clearer. Generated methods must reconcile field order, defaults, equality, and initialization across the hierarchy. An Address stored by a Customer is generally easier to evolve than a deep tree of data-only subclasses.

Select the appropriate model

Use a dataclass when an object has known named fields, benefits from readable representation and equality, and has a few invariants. Use NamedTuple for a compact immutable tuple whose positional behavior matters. Use TypedDict when the runtime value must remain a dictionary, often at a JSON-shaped boundary. Choose a regular class when behavior, encapsulation, or a custom construction lifecycle dominates.

This distinction improves APIs. Accepting a dataclass says the application owns a model; accepting an arbitrary mapping communicates a looser contract. Converting validated external data into a dataclass at the boundary prevents dictionary keys from spreading through business code.

Before enabling slots=True, profile the workload and check inheritance requirements. Slotted classes omit the normal instance __dict__ unless configured otherwise, so dynamic attributes and some reflection patterns change. weakref_slot=True adds weak-reference support when needed. The optimization can matter for many small instances, but should follow measurement.

frozen=True blocks ordinary assignment after construction and is useful for value-like objects. It does not make nested objects recursively immutable. slots=True can reduce memory and prevent accidental attributes, but add it for a measured reason rather than by habit.

Never define a mutable field as items: list[str] = []. Use field(default_factory=list) so each instance receives its own list. Keep __post_init__ focused on short invariants; extensive input conversion and validation often belong in a dedicated boundary layer.

Annotations remain annotations. Pair dataclasses with Python type hints and a static checker when type contracts matter. A hand-written class may still express a behavior-rich domain object more clearly.

The official dataclasses documentation, accessed July 22, 2026, covers field, frozen instances, slots, inheritance, and generated method behavior.

Practical review checklist

Instantiate the smallest valid case, compare equivalent values, and inspect the representation. Confirm that mutable defaults use factories and confidential fields are excluded from repr when logs must not expose them. Exercise every failed invariant and check that its exception explains the correction. If instances become keys, prove their compared state cannot change.

Review the constructor as a caller. Too many optional fields may reveal several concepts combined. Boolean switches may deserve an enum or named factories. A construction-only input may be an InitVar; a derived value should not also be independently supplied.

Finally, do not confuse convenience with validation. Run a type checker over representative calls and test runtime boundaries with malformed values. Benchmark slots with realistic instance counts, and verify serialization explicitly instead of assuming asdict() defines the external contract.