# Dataclasses

> Source: https://learn-python.com/dataclasses/
> Part of Learn Python, free to read.

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=[], ...)
```

:::danger `field(default_factory=...)` for anything mutable
```python
tags: list[str] = []         # ValueError: mutable default
```
Dataclasses raise at class-definition time rather than letting you create the [shared-mutable-default bug](/review/failure-modes/). This is one of the few places Python protects you from that mistake, and it is a good reason to use a dataclass instead of a hand-written `__init__` where the equivalent code fails silently.
:::

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)
```

| Option | Effect |
|---|---|
| `frozen=True` | immutable and hashable |
| `slots=True` | faster attribute access, less memory, no accidental new attributes |
| `kw_only=True` | callers must use keyword arguments — good for wide classes |
| `order=True` | generates `<`, `<=`, `>`, `>=` for sorting |

And per field:

| `field(...)` | Effect |
|---|---|
| `default_factory=` | a fresh value per instance |
| `compare=False` | excluded from `==` and ordering |
| `repr=False` | hidden from `__repr__` — use for secrets and large blobs |
| `init=False` | not 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

| Use | When |
|---|---|
| `dict` | shape is genuinely dynamic, or it is just JSON passing through |
| `NamedTuple` | a small immutable record you want to unpack like a tuple |
| `@dataclass` | **the default** for structured data you own |
| `pydantic` model | data crossing a boundary that needs runtime validation |
| plain class | behaviour-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)`.
