runtime
136 / 250
lesson 136 Data Models & Typing · advanced

Dataclasses: records without the boilerplate

A class that mainly holds data needs the same __init__, the same readable printout and the same comparison every time. @dataclass writes them for you from a list of annotated fields.

You'll be able to
  • Declare fields as name: type (with defaults) and let @dataclass write __init__, a readable printout and ==
  • Use field(default_factory=list) for a list per object, and __post_init__ to tidy and validate
  • Make an immutable, hashable value with @dataclass(frozen=True)
The idea

The classes in the Classes & Objects module repeated self.sku = sku for every attribute, printed as <__main__.Product object at 0x…>, and compared by identity. For classes whose job is mostly to hold data, the standard library's dataclasses module writes that code for you.

Put @dataclass on the line above class and list the fields with annotations: sku: str means "a field called sku, holding text". The decorator reads that list and adds an __init__ taking the fields in order, a printout showing every field, and == that compares the fields. A field with = value has a default, and fields with defaults must come after those without.

Run each example to see what it prints. Running examples doesn't count toward progress.

example-1.py · A product in five lines
from dataclasses import dataclass

@dataclass
class Product:
    sku: str
    name: str
    price: float
    stock: int = 0

mat = Product("SPO-11681", "Yoga mat", 31.0, 12)
lamp = Product("HOM-22847", "Desk lamp", 48.0)
print(mat)
print(lamp.stock, lamp == Product("HOM-22847", "Desk lamp", 48.0))
mat.stock -= 2
print(mat.stock, mat == Product("SPO-11681", "Yoga mat", 31.0, 12))
example-2.py · A list as a field default
from dataclasses import dataclass

@dataclass
class Basket:
    customer_id: str
    items: list = []

print(Basket("C00038"))

Three more tools cover most real data classes:

  • field(default_factory=list) calls list() for every new object, so each gets its own list.
  • __post_init__(self) runs right after the generated __init__: the place to tidy and validate fields (raise ValueError, as in the Classes & Objects module).
  • @dataclass(frozen=True) makes objects read-only. Assigning a field raises FrozenInstanceError (a kind of AttributeError). Frozen objects with equal fields hash equally, so they work in sets and as dictionary keys, which suits values like money or a (warehouse, SKU) pair.
example-3.py · Orders, lines and money
from dataclasses import dataclass, field

@dataclass
class OrderLine:
    sku: str
    quantity: int
    unit_price: float

    def __post_init__(self):
        self.sku = self.sku.strip().upper()
        if self.quantity < 1:
            raise ValueError(f"{self.sku}: quantity must be at least 1")

@dataclass
class Order:
    order_id: str
    customer_id: str
    lines: list = field(default_factory=list)

    def total(self):
        return round(sum(line.quantity * line.unit_price for line in self.lines), 2)

@dataclass(frozen=True)
class Money:
    cents: int
    currency: str

first = Order("O100282", "C00077")
second = Order("O100918", "C01338")
first.lines.append(OrderLine(" spo-11681", 2, 17.92))
print(first.total(), second.lines)
print(first.lines[0])
price = Money(1250, "GBP")
try:
    price.cents = 999
except AttributeError as err:
    print(type(err).__name__, err)
print(price == Money(1250, "GBP"), len({price, Money(1250, "GBP"), Money(1250, "EUR")}))

When to use it: for records: rows from a file, API payloads, configuration, value objects. Keep ordinary classes for objects whose main job is behaviour.

Mistakes to avoid: expecting annotations to convert or check types (Product("A", "B", "31") stores the text "31"; validate in __post_init__); a list default instead of field(default_factory=list); and a field without a default after one with a default (a TypeError when the class is defined).

Review
  • @dataclass writes __init__, a readable printout and field-by-field == from name: type lines.
  • Use field(default_factory=list) for per-object lists and __post_init__ to tidy and validate.
  • frozen=True makes read-only, hashable values.
  • Next: properties, for attributes that are computed or checked when they are read or set.
Practise this

Loading your progress…

To master this lesson, solve both challenges without hints or the answer, and pass the concept check.

Dataclasses: records without the boilerplate — Data Models & Typing · Runtime