Skillversitas

Dataclasses

A dataclass is a class declared as a bag of typed fields, and the @dataclass decorator generates the boilerplate you would otherwise write by hand: __init__, __repr__, and (optionally) __eq__. For the large class of objects that exist to carry data - a Point, a Config, a Record read from a database or an API - it collapses thirty lines of constructor-and-repr into the field declarations. This chapter covers the core decorator, the field options that round it out (defaults, field(), immutability), and the comparison and inheritance rules that make dataclasses safe to build on.

The Core: Fields Generate the Methods

from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float
    label: str = "origin"     # a default, after the required fields

p = Point(1.0, 2.0)
print(p)               # Point(x=1.0, y=2.0, label='origin') - free __repr__
print(p.x, p.y)        # 1.0 2.0 - free __init__ assigned them
q = Point(1.0, 2.0)
print(p == q)          # True - free __eq__ compares field tuples

# the generated __init__ signature is visible and inspectable
import inspect
print(inspect.signature(Point.__init__))
# (self, x: float, y: float, label: str = 'origin') -> None

The decorator reads the class's annotated fields and synthesizes __init__ (one parameter per field, in declaration order, defaults where given), __repr__ (a Point(x=1.0, ...) string), and __eq__ (a field-wise comparison). The ordering rule is enforced: fields without defaults must come before fields with defaults, or the decorator raises - the same constraint as a hand-written __init__, now checked for you. The generated __eq__ compares only fields, ignores non-field attributes, and returns NotImplemented for objects of a different class - the correct behavior from the dunder chapter, generated. For pure data, this is the entire value: the class's public interface is exactly its fields, and the three methods that used to be copy-paste are now correct by construction.

Field Options: Defaults, Factories, Metadata

from dataclasses import dataclass, field

@dataclass
class Task:
    title: str
    tags: list[str] = field(default_factory=list)   # a NEW list per instance
    priority: int = 0
    notes: dict[str, str] = field(default_factory=dict,
                                   metadata={"max_len": 200})

a = Task("write docs")
b = Task("review")
a.tags.append("docs")
print(a.tags)      # ['docs']
print(b.tags)      # [] - the factory gave b its own list
print(a.priority)  # 0 - the plain default
print(Task.tags.metadata if hasattr(Task, "tags") else "n/a")

Two default mechanisms, and the rule between them: a plain default (priority: int = 0) for immutable values, and field(default_factory=...) for mutable ones - because a mutable default is evaluated once at class creation, so tags: list = [] would share one list across all instances (the exact class-attribute trap from chapter 4, now the documented reason for the factory). field carries more: metadata (an arbitrary dict - the hook serialization libraries use to attach format hints), init=False (a field the constructor does not take - computed or injected later), repr=False (hide it from __repr__, the standard move for a password field), and compare=False (exclude from __eq__, for a monotonic id that should not affect equality). The pattern to internalize: mutable per-instance state is always a default_factory, never a literal.

Immutability: frozen Dataclasses

from dataclasses import dataclass

@dataclass(frozen=True)
class Currency:
    code: str
    rate: float

usd = Currency("USD", 1.0)
try:
    usd.rate = 1.1          # FrozenInstanceError
except Exception as e:
    print(type(e).__name__) # FrozenInstanceError

# frozen implies hashable on the fields - safe as a dict key
rates = {usd: "base"}
print(usd in rates)         # True

frozen=True makes the instance immutable: any attribute assignment after construction raises FrozenInstanceError. The decorator also generates a __hash__ consistent with __eq__, so a frozen dataclass is a proper value type - hashable, settable, dict-keyable, comparable - the full value-object treatment the operator chapter described, for free. The design rule: when the object is a value (a coordinate, a currency pair, a configuration) that should never change after creation, freeze it; when it is a record with a lifecycle (a task that accumulates tags), leave it mutable and let the fields evolve. Freezing is the cheapest guarantee you can give a value type, and the FrozenInstanceError at the assignment site is the enforcement.

Inheritance and the gotchas

from dataclasses import dataclass

@dataclass
class Animal:
    name: str
    legs: int = 4

@dataclass
class Dog(Animal):
    breed: str = "mixed"

d = Dog("Rex")
print(d)            # Dog(name='Rex', legs=4, breed='mixed')
print(d == Animal("Rex"))   # False - different classes

# the mutable-default trap still applies across inheritance:
@dataclass
class Broken:
    items: list = []          # ONE list for every Broken

@dataclass
class Fixed:
    items: list = field(default_factory=list)

A dataclass can inherit from another dataclass: the child's fields are appended to the parent's in the generated __init__, and the ordering rule (no-default before default) applies to the combined list, which is where inheritance surprises live - a child adding a required field after the parent's defaulted ones is an error. __eq__ across different classes returns False (the generated equality checks the type), which is the safe default; two dataclasses that should compare equal across a hierarchy need a custom __eq__. The mutable-default rule does not get softer with inheritance - a list = [] on a base is shared by every subclass instance, so the default_factory discipline carries through. Inheritance here is for genuinely related records (a base record plus an enriched subclass), not for sharing a few fields.

When a Dataclass Is the Right Tool

  • Use it when the object is primarily data with a stable field set: records, configurations, API payloads, value types. The generated methods are exactly the boilerplate, and the fields are the documentation.
  • Use a regular class when behavior dominates - the fields are a small part of a class that is mostly methods - or when the constructor needs real logic (validation that spans fields, I/O) beyond what __post_init__ should carry.
  • __post_init__ is the hook for constructor logic in a dataclass: it runs at the end of the generated __init__, so field-spanning validation and derived-field computation belong there.

Summary

Declare @dataclass over a class of annotated fields and get __init__, __repr__, and __eq__ generated in field order, with the no-default-before-default rule enforced. Use field(default_factory=...) for every mutable default (the shared-list trap, now a documented idiom), and the field options - metadata, init=False, repr=False, compare=False - for the rest. Freeze value types with frozen=True to get immutability plus a consistent __hash__. Inherit only for genuinely related records, and put any real constructor logic in __post_init__. A dataclass is the honest shape for "this object is its fields" - the moment behavior takes over, it is a regular class again.