pyguides

Advanced Python Dataclass Patterns and Best Practices

Advanced dataclass patterns cover everything past the intro guide: derived fields, frozen instances, slot-based memory savings, and clean inheritance. These are the corners where the defaults quietly mislead you in production code. This guide walks through field() configuration, __post_init__, InitVar, KW_ONLY, frozen=True, slots=True, and the inheritance rules that bite when you add a field to a base class that already has defaults.

The basics guide covers @dataclass, auto-generated dunder methods, and simple field(default_factory=list). Skip back to it if any of that is fuzzy. The signatures and gotchas below are verified against the Python 3.14 docs.

field() in depth

field() is the configuration knob behind every dataclass attribute. The full signature in Python 3.14:

dataclasses.field(
    *,
    default=MISSING,
    default_factory=MISSING,
    init=True,
    repr=True,
    hash=None,
    compare=True,
    metadata=None,
    kw_only=MISSING,
    doc=None,
)

default and default_factory are mutually exclusive. Pick one. The per-field overrides that come up most:

  • init=False: keep the field out of the generated __init__. Pair it with a __post_init__ to compute the value.
  • repr=False: hide sensitive data (passwords, tokens) from the default __repr__.
  • compare=False: exclude from __eq__ and the generated ordering methods.
  • hash=None: inherits compare; only override for the rare case of a field that’s expensive to hash but still belongs in __eq__.
  • metadata: read-only MappingProxyType; the standard library does not use it. It’s the extension hook for validators, ORMs, and JSON-schema tools.
  • kw_only=MISSING: inherits the class-level kw_only; set True/False to override per field.
  • doc=None: Python 3.14 adds a docstring to the generated __init__ parameter.

A practical example: hide a token from __repr__ and exclude it from equality:

from dataclasses import dataclass, field

@dataclass
class ApiClient:
    base_url: str
    token: str = field(repr=False, compare=False)

c = ApiClient("https://api.example.com", "secret-token")
print(c)
# output: ApiClient(base_url='https://api.example.com')

Mutable defaults and default_factory

@dataclass refuses field: list = [] outright. Assigning a mutable default at class-definition time would share one list across every instance, and the kind of bug that creates (one tenant mutating another’s data) is exactly what dataclasses exist to prevent. The error is loud for a reason:

from dataclasses import dataclass

@dataclass
class Order:
    items: list = []
# output:
# ValueError: mutable default <class 'list'> for field items is not allowed:
# use default_factory

field(default_factory=list) calls list() once per instance. The factory runs at construction time, so each instance gets its own empty list. Use it for any mutable container, or for any object that should be reconstructed each time. The same pattern works for set, dict, and any custom zero-argument callable you want to attach as a default:

from dataclasses import dataclass, field
from typing import List

@dataclass
class Order:
    items: List[str] = field(default_factory=list)
    tags:  set       = field(default_factory=set)

For values that should be shared (a default empty tuple, for example), use an immutable literal. Tuples are fine because they cannot be mutated, and Python is happy to share one tuple reference across instances. The mutable-default check only fires for types that are obviously mutable, so frozen tuples and frozensets are accepted as-is:

@dataclass
class Point:
    label: str
    coords: tuple = ()

__post_init__ patterns

__post_init__ runs as the last step of the generated __init__. Three patterns earn their place in everyday code: derived fields, cross-field validation, and InitVar for input that is not stored. Each one solves a different problem, and all three are worth knowing before you reach for something heavier like pydantic.

Derived fields

Derived fields are computed from other fields and marked init=False so they don’t appear in __init__. The classic case is a circle’s area from its radius:

from dataclasses import dataclass, field

@dataclass
class Circle:
    radius: float
    area: float = field(init=False)

    def __post_init__(self) -> None:
        self.area = 3.141592653589793 * self.radius ** 2

c = Circle(2.0)
print(c.area)
# output: 12.566370614359172

init=False keeps area out of __init__ but __post_init__ still sees self.radius and can write self.area directly. This pattern works well for fields that are pure functions of other fields, but watch out for the replace() gotcha covered later in the article. Cached derived fields go stale when you functionally update the source of truth.

Cross-field validation

Use __post_init__ for invariants between fields. The check runs once at construction time, so you cannot end up with an invalid instance sitting in memory:

from dataclasses import dataclass
import datetime

@dataclass
class DateRange:
    start: datetime.date
    end:   datetime.date

    def __post_init__(self) -> None:
        if self.end < self.start:
            raise ValueError(f"end {self.end} is before start {self.start}")

For complex validation (deserialisation, multiple input sources, conditional rules), a regular class with a hand-rolled __init__ that raises on bad input reads more cleanly than stacking if statements in __post_init__. Dataclasses push you toward __post_init__, which is fine for one or two checks, but a normal class scales better once the rule set grows past a handful of invariants.

InitVar: input that is not stored

InitVar (PEP 557) marks a field that shows up in __init__ and is passed to __post_init__, but is never assigned to self. Use it when you need a value only to compute a stored field. The classic example is a cylinder’s mass from its dimensions and density, where density is consumed during construction but is not part of the persistent state:

from dataclasses import dataclass, field, InitVar

@dataclass
class Cylinder:
    radius: float
    height: float
    density: InitVar[float] = 1.0          # init-only, not stored
    mass: float = field(init=False, default=0.0)

    def __post_init__(self, density: float) -> None:
        self.mass = 3.141592653589793 * self.radius ** 2 * self.height * density

cyl = Cylinder(2.0, 3.0, density=7.85)
print(cyl.mass)
# output: 295.6245229568472

Notice that density does not show up in the instance’s attribute list and is not stored. It exists only to be consumed by __post_init__. This is the cleanest way to express “input that is not part of the object’s persistent state,” and it beats reaching for **kwargs in a hand-rolled __init__.

Frozen dataclasses

@dataclass(frozen=True) makes instances hashable and prevents reassignment. Any self.x = ... inside __post_init__ raises dataclasses.FrozenInstanceError (which subclasses AttributeError, so existing except AttributeError blocks catch it).

For derived fields on a frozen class, escape the lock with object.__setattr__. This is the one sanctioned escape hatch from the frozen contract, and it only works inside __post_init__:

from dataclasses import dataclass, field

@dataclass(frozen=True)
class FrozenCircle:
    radius: float
    area: float = field(init=False)

    def __post_init__(self) -> None:
        object.__setattr__(self, "area", 3.141592653589793 * self.radius ** 2)

c = FrozenCircle(2.0)
{c}                          # works, frozen instances are hashable
# output: {FrozenCircle(radius=2.0, area=12.566370614359172)}

Defining your own __setattr__ or __delattr__ while frozen=True is active raises TypeError. Pick one or the other. The frozen mechanism is a deliberate trade: you give up the ability to mutate the instance after construction in exchange for hashability and a guarantee that the object’s state is fixed at the point of creation.

KW_ONLY and keyword-only fields (Python 3.10+)

KW_ONLY is a sentinel marker. Place it on a class variable named _ and every field after it becomes keyword-only in the generated __init__. This is the cleanest way to mix required positional fields with optional keyword-only fields without resorting to **kwargs in your __init__:

from dataclasses import dataclass, field, KW_ONLY

@dataclass
class HttpRequest:
    method: str
    url: str
    _: KW_ONLY
    timeout: float = 30.0
    headers: dict = field(default_factory=dict)

r = HttpRequest("GET", "https://example.com", timeout=5.0)
# output: HttpRequest(method='GET', url='https://example.com', timeout=5.0, headers={})

HttpRequest("GET", "https://example.com", 5.0) fails with a TypeError because timeout and headers are keyword-only. That is the point. Callers cannot accidentally swap timeout and headers positionally, and adding a new optional field later does not break any positional callers.

Inheritance gotchas

Two rules govern dataclass inheritance, and both catch people off guard. The first is about field ordering, the second is about __post_init__ chaining. Both are mechanical once you know them, but they are easy to miss if you have not seen them before.

Rule 1: field order (defaults after non-defaults)

Adding a field without a default to a subclass whose parent has a default-valued field raises TypeError. Python’s regular function-signature rules apply to the generated __init__, and you cannot have a required parameter after an optional one:

from dataclasses import dataclass

@dataclass
class Animal:
    name: str
    sound: str = "silence"

# @dataclass
# class Dog(Animal):
#     species: str
# output: TypeError: non-default argument 'species' follows default argument 'sound'

Two fixes exist for this. The first is to give the new field a default value, which works when the subclass field is genuinely optional. The second is to mark it keyword-only with KW_ONLY, which keeps the field conceptually required but moves it out of the positional argument list. The keyword-only route is usually the right one for subclass fields that represent new required information, because it preserves the “must be provided” semantics while sidestepping the ordering constraint:

@dataclass
class Dog(Animal):
    species: str = "Canis familiaris"

Rule 2: __post_init__ and MRO

If a subclass defines its own __post_init__, call super().__post_init__() or the parent’s logic will silently skip. Dataclasses do not chain __post_init__ automatically the way regular methods chain via MRO. You have to do it explicitly, and forgetting it is the single most common inheritance bug:

@dataclass
class Animal:
    name: str
    sound: str = "silence"
    def __post_init__(self) -> None:
        if not self.name:
            raise ValueError("name required")

@dataclass
class Dog(Animal):
    species: str = "Canis familiaris"
    def __post_init__(self) -> None:
        super().__post_init__()
        # dog-specific checks here

The “validate name” check above never runs for Dog without the super() call, and the failure mode is silent: no error, just a missing validation step. This is the kind of bug that passes code review and slips into production. The fix is mechanical once you know to look for it: always call super().__post_init__() as the first line of a subclass’s __post_init__.

slots=True and the __init_subclass__ trap (Python 3.10+)

@dataclass(slots=True) generates __slots__, removes the per-instance __dict__, and returns a new class object (different from the no-slots case, which mutates and returns the original). The win is real: measurably less memory and a small speed-up on attribute access. The class is no longer extensible via arbitrary attribute assignment, which can be a feature or a constraint depending on your use case.

weakref_slot=True (Python 3.11+) adds a __weakref__ slot so the class can be the target of weakref.ref(). It requires slots=True; passing it without slots raises TypeError. Without this slot, instances of a slotted class cannot be weakly referenced, which matters if you want to use them in caches or observer patterns.

The trap is __init_subclass__. Passing keyword arguments to a base class’s __init_subclass__ while using slots=True raises TypeError. This is gh-91126:

from dataclasses import dataclass

@dataclass(slots=True)
class Base:
    x: int

# class Child(Base, some_kw="hello"): ...   # TypeError with slots=True
# class Child(Base):
#     y: int                                 # works

If you control the base class, give the __init_subclass__ parameters defaults so the slot path still works. Alternatively, accept no parameters at all in __init_subclass__ and use class-level state instead of kwargs.

__match_args__ and pattern matching (Python 3.10+)

@dataclass automatically generates __match_args__, a tuple of the non-kw-only __init__ parameter names. match/case uses it for positional binding. This is what makes dataclass instances feel natural in structural pattern matching, without you having to declare anything extra:

from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float
    z: float = 0.0

def describe(p: Point) -> str:
    match p:
        case Point(0, 0, 0):
            return "origin"
        case Point(x, 0, 0):
            return f"on x-axis at {x}"
        case Point(x, y, 0):
            return f"in xy-plane: ({x}, {y})"
        case Point(x, y, z):
            return f"3d: ({x}, {y}, {z})"

Keyword-only fields are not in __match_args__, so they cannot be matched positionally. Match them with the keyword form: case Point(x=0, y=0, z=0). This also means that if you switch a field to keyword-only to solve an inheritance ordering problem, you lose the ability to match on it positionally. The trade is usually worth it, but it is worth knowing.

Helper functions worth memorising

The four helpers you will reach for most often: fields(), asdict(), replace(), and is_dataclass(). Each one does one job and does it well.

fields() returns a tuple of Field objects for introspection. metadata is a MappingProxyType, read-only, so callers cannot mutate it through the field. This is what makes metadata a safe place to stash configuration for third-party extensions (validators, ORMs, JSON-schema tools) without worrying about aliasing bugs across instances.

asdict() and astuple() recurse into nested dataclasses, lists, and tuples. To customise the conversion, pass dict_factory=OrderedDict (or your own factory) to preserve order semantics or strip fields. Both functions are shallow in the sense that they do not call __post_init__ on the result, so derived fields end up as their stored values.

replace(obj, **changes) returns a new instance of the same type with the listed fields replaced. Use it for functional updates: replace(user, name="Ada"). The gotcha here is that init=False fields keep their old value, which we cover in the next section.

is_dataclass() returns True for both @dataclass instances and the classes themselves, which makes it useful for duck-typing checks at boundaries. If you are writing a function that accepts “anything that quacks like a dataclass,” is_dataclass(obj) is the right check.

The replace() + init=False gotcha

replace() calls __init__ with the merged kwargs. Fields declared with init=False keep their old value, even when the field they were derived from has changed. This is the well-known ericvsmith/dataclasses issue #74, and it catches people regularly:

from dataclasses import dataclass, field, replace

@dataclass
class Rectangle:
    width: float
    height: float
    area: float = field(init=False)

    def __post_init__(self) -> None:
        self.area = self.width * self.height

r = Rectangle(2, 3)
r2 = replace(r, width=4)
print(r2.area)        # stale
# output: 6

The fix is one of three:

  1. Recompute manually after replace: r2.area = r2.width * r2.height. Simple, but easy to forget at every call site.
  2. Make __post_init__ idempotent and call it from a helper. Centralises the recomputation, adds a method.
  3. Skip the cached field entirely. Use a @property so the value is always derived from the source of truth. This is the route I reach for most often because it removes the failure mode rather than working around it:
from dataclasses import dataclass, replace

@dataclass
class Better:
    width: float
    height: float

    @property
    def area(self) -> float:
        return self.width * self.height

b = Better(2, 3)
b2 = replace(b, width=4)
print(b2.area)
# output: 12

For most cases, the @property route is cleaner. The trade-off is that the property runs every time you access it, so if the computation is expensive and the dimensions change rarely, caching as a field is faster. The replace() gotcha is the price of that caching.

When @dataclass is the wrong tool

@dataclass is excellent for plain data containers, value objects, and DTOs. It is the wrong tool when:

  • You need runtime validation (deserialising JSON, parsing user input). Reach for pydantic.BaseModel or pydantic.dataclasses.dataclass. Pydantic ships a dataclass-compatible decorator that adds validators, JSON schema, and FastAPI integration.
  • You need structural shape and immutability together with maximum performance. msgspec.Struct is faster than @dataclass(slots=True) and gives you built-in validation. The trade-off is a smaller ecosystem and fewer learning resources.
  • You want a typed, immutable tuple. typing.NamedTuple covers that with default values and methods. It is the right answer for small records that should never change.
  • You have heavy invariant logic that belongs in a regular class with a hand-rolled __init__ that raises on bad input. Dataclasses push you toward __post_init__, which is fine for one or two checks, but a normal class reads more cleanly once the rule set grows.

The honest answer for most teams: use @dataclass for internal data, pydantic for boundaries, NamedTuple for tiny immutable records, and msgspec.Struct when every microsecond matters. Pick the simplest tool that handles the validation and performance needs of the specific boundary you are working at.

Conclusion

The advanced dataclass patterns worth reaching for first: field(default_factory=...) for mutables, __post_init__ for derived fields and invariants, InitVar for input-only data, KW_ONLY to break the inheritance-defaults deadlock, frozen=True for hashable value objects, and slots=True when memory or attribute-access speed matters. Skip init=False cached derived fields in favour of @property whenever you would otherwise run into the replace() staleness trap.

See Also