Foundations Updated 2026-09 View as Markdown

Dataclasses

A decorator that writes the boilerplate for classes that mostly hold data — which is most classes.

Most classes exist to hold a few related values. Written by hand that is a lot of repetition:

python
class Point:
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Point(x={self.x!r}, y={self.y!r})"

    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return (self.x, self.y) == (other.x, other.y)
python
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

Identical behaviour. The decorator reads the annotations and generates __init__, __repr__ and __eq__.

python
p = Point(1.0, 2.0)
print(p)                    # Point(x=1.0, y=2.0)
p == Point(1.0, 2.0)        # True

That __repr__ alone justifies the decorator — a hand-written class prints as <__main__.Point object at 0x10d4f2>, which tells you nothing in a log or a debugger.

Defaults#

python
from dataclasses import dataclass, field

@dataclass
class Config:
    host: str                                    # required
    port: int = 8080                             # optional
    tags: list[str] = field(default_factory=list)  # mutable — MUST use a factory
    created: datetime = field(default_factory=lambda: datetime.now(UTC))
python
Config("localhost")          # Config(host='localhost', port=8080, tags=[], ...)

As with function parameters, fields with defaults must come after those without.

Frozen — immutable instances#

python
@dataclass(frozen=True)
class Money:
    amount_cents: int
    currency: str = "USD"

m = Money(1999)
m.amount_cents = 0          # FrozenInstanceError

frozen=True also makes the instance hashable, so it works as a dictionary key or in a set:

python
prices = {Money(1999): "standard", Money(2999): "premium"}

Use frozen by default for value objects — anything representing a measurement, an identifier, a coordinate or an amount. Mutability should be a decision, not an accident.

Validation#

__post_init__ runs after the generated __init__:

python
@dataclass(frozen=True)
class Money:
    amount_cents: int
    currency: str = "USD"

    def __post_init__(self):
        if self.amount_cents < 0:
            raise ValueError("amount cannot be negative")
        if len(self.currency) != 3:
            raise ValueError(f"currency must be a 3-letter code, got {self.currency!r}")

Now an invalid Money cannot exist. Enforcing invariants in the constructor is what makes a value type trustworthy everywhere else in the program.

For a frozen class, use object.__setattr__ if you must normalise a field:

python
    def __post_init__(self):
        object.__setattr__(self, "currency", self.currency.upper())

Useful options#

python
@dataclass(frozen=True, slots=True, kw_only=True, order=True)
class Event:
    timestamp: datetime
    name: str
    payload: dict[str, str] = field(default_factory=dict, compare=False, repr=False)
OptionEffect
frozen=Trueimmutable and hashable
slots=Truefaster attribute access, less memory, no accidental new attributes
kw_only=Truecallers must use keyword arguments — good for wide classes
order=Truegenerates <, <=, >, >= for sorting

And per field:

field(...)Effect
default_factory=a fresh value per instance
compare=Falseexcluded from == and ordering
repr=Falsehidden from __repr__ — use for secrets and large blobs
init=Falsenot a constructor parameter; set in __post_init__

repr=False deserves a mention: a dataclass holding a password hash or an API key will print it in every log line and traceback unless you exclude it.

Methods and inheritance#

A dataclass is an ordinary class. Add whatever you like:

python
@dataclass(frozen=True)
class Money:
    amount_cents: int
    currency: str = "USD"

    def __add__(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise ValueError("cannot add different currencies")
        return Money(self.amount_cents + other.amount_cents, self.currency)

    @property
    def display(self) -> str:
        return f"{self.amount_cents / 100:.2f} {self.currency}"

    @classmethod
    def from_string(cls, s: str) -> "Money":
        return cls(int(round(float(s) * 100)))

Helpers#

python
from dataclasses import asdict, astuple, replace, fields

asdict(point)               # {"x": 1.0, "y": 2.0} — recursive
astuple(point)              # (1.0, 2.0)
replace(point, x=5.0)       # a NEW instance with one field changed
[f.name for f in fields(Point)]

replace is how you "modify" a frozen instance — it returns a copy, leaving the original alone.

What to use when#

UseWhen
dictshape is genuinely dynamic, or it is just JSON passing through
NamedTuplea small immutable record you want to unpack like a tuple
@dataclassthe default for structured data you own
pydantic modeldata crossing a boundary that needs runtime validation
plain classbehaviour-heavy, with little state

The distinction that matters most: a dataclass does not validate types at runtime. Point("a", "b") constructs happily — the annotations are hints, checked by mypy and ignored by Python. For data arriving from a request or a config file, use a validating library so something actually verifies it.

Exercise#

python
from dataclasses import dataclass, field

# Build a frozen `Order` dataclass with:
#   - id: str, customer: str
#   - lines: list of (sku, qty, unit_cents) tuples, defaulting to empty
#   - a `total_cents` property
#   - __post_init__ rejecting an empty id
#   - the lines field excluded from __repr__
# Then create one, print it, and use replace() to change the customer.

# write your code here

Common questions#

Dataclass or pydantic?#

Dataclass for internal data you construct yourself — it is standard library, has no dependency and no runtime overhead. Pydantic when data arrives from outside and must be validated and coerced, which dataclasses do not do.

Should I use slots=True?#

Usually yes for classes you create many of — it reduces memory and speeds attribute access. The trade-offs are that you cannot add attributes dynamically and multiple inheritance gets fiddlier, neither of which matters for a typical value object.

Why did my mutable default raise an error?#

Because a bare [] or {} as a class-level default would be shared by every instance. Dataclasses detect this and refuse, which is safer than the silent sharing you get from a hand-written __init__. Use field(default_factory=list).

Get the Python agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for Python. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.