Dataclasses

A dataclass generates __init__, __repr__ and __eq__ from field declarations. It removes the boilerplate from every class whose main job is holding data.

The boilerplate it removes

class Product:
    def __init__(self, name, price, quantity=0):
        self.name = name
        self.price = price
        self.quantity = quantity

    def __repr__(self):
        return (f"Product(name={self.name!r}, price={self.price!r}, "
                f"quantity={self.quantity!r})")

    def __eq__(self, other):
        if not isinstance(other, Product):
            return NotImplemented
        return ((self.name, self.price, self.quantity)
                == (other.name, other.price, other.quantity))
from dataclasses import dataclass


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


p = Product("Notebook", 250)
print(p)                                    # Product(name='Notebook', price=250, quantity=0)
print(p == Product("Notebook", 250))        # True
print(p.name, p.price)

Three lines replaced eighteen. The annotations are what declare the fields; without them nothing is generated.

What gets generated

ParameterDefaultGenerates
initTrue__init__
reprTrue__repr__
eqTrue__eq__
orderFalse__lt__, __le__, __gt__, __ge__
frozenFalseImmutable instances
slotsFalse__slots__, saving memory
kw_onlyFalseAll fields keyword only
from dataclasses import dataclass


@dataclass(order=True, frozen=True, slots=True)
class Version:
    major: int
    minor: int
    patch: int = 0


versions = [Version(1, 2), Version(1, 10), Version(0, 9, 3)]
print(sorted(versions))            # ordering was generated
print(Version(1, 2) < Version(1, 10))
print(len({Version(1, 0), Version(1, 0)}))     # frozen, so hashable

# Version(1, 2).major = 5         # FrozenInstanceError

order=True compares fields as a tuple, in declaration order. That is why major is declared first.

Fields with defaults

from dataclasses import dataclass, field


@dataclass
class Cart:
    customer: str
    items: list = field(default_factory=list)      # a NEW list per instance
    tags: set = field(default_factory=set)
    metadata: dict = field(default_factory=dict)


a = Cart("Meera")
b = Cart("Arun")
a.items.append("pen")
print(a.items, b.items)          # ['pen'] [] - independent
# This is rejected outright, which is a good thing
# @dataclass
# class Broken:
#     items: list = []
# ValueError: mutable default <class 'list'> for field items is not allowed
Dataclasses refuse a mutable default rather than letting you create the classic shared-state bug. default_factory calls the factory once per instance.

field options

from dataclasses import dataclass, field


@dataclass
class User:
    username: str
    email: str
    password: str = field(repr=False)                    # hidden from repr
    login_count: int = field(default=0, compare=False)   # ignored by ==
    session: str = field(default="", init=False)         # not a constructor argument
    tags: list = field(default_factory=list)
    notes: str = field(default="", metadata={"help": "free text"})


u = User("meera", "m@example.com", "secret")
print(u)                    # the password does not appear
print(u == User("meera", "m@example.com", "secret", login_count=99))   # True

__post_init__

from dataclasses import dataclass, field


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

    def __post_init__(self):
        if self.width <= 0 or self.height <= 0:
            raise ValueError("dimensions must be positive")
        self.area = self.width * self.height


r = Rectangle(3, 4)
print(r)

try:
    Rectangle(-1, 4)
except ValueError as error:
    print(error)

__post_init__ runs immediately after the generated __init__. It is where validation and derived fields belong.

Methods and properties still work

from dataclasses import dataclass, field
from datetime import date


@dataclass
class Invoice:
    customer: str
    lines: list = field(default_factory=list)
    issued_on: date = field(default_factory=date.today)
    TAX_RATE = 0.18                       # no annotation, so NOT a field

    def add(self, description, price, quantity=1):
        self.lines.append((description, price, quantity))
        return self

    @property
    def subtotal(self):
        return sum(price * qty for _, price, qty in self.lines)

    @property
    def total(self):
        return round(self.subtotal * (1 + self.TAX_RATE), 2)

    def __str__(self):
        rows = "\n".join(
            f"  {d:<20}{q:>3} x {p:>8,.2f}" for d, p, q in self.lines
        )
        return f"Invoice for {self.customer}\n{rows}\n  TOTAL {self.total:>16,.2f}"


invoice = Invoice("Meera").add("Notebook", 45.50, 3).add("Pen", 8.75, 12)
print(invoice)

A class attribute without an annotation is not a field. That is how TAX_RATE stays a shared constant and out of __init__.

Helper functions

from dataclasses import dataclass, asdict, astuple, replace, fields, is_dataclass


@dataclass
class Point:
    x: int
    y: int
    label: str = ""


p = Point(3, 7, "origin")

print(asdict(p))                    # {'x': 3, 'y': 7, 'label': 'origin'}
print(astuple(p))                   # (3, 7, 'origin')
print(replace(p, x=10))             # a NEW Point with x changed
print(is_dataclass(p))              # True

for f in fields(p):
    print(f"{f.name:<8}{f.type!s:<10}default={f.default!r}")

asdict recurses into nested dataclasses, lists and dictionaries, which makes it the natural bridge to JSON.

import json
from dataclasses import dataclass, asdict, field


@dataclass
class Address:
    city: str
    pin: str


@dataclass
class Person:
    name: str
    age: int
    address: Address
    tags: list = field(default_factory=list)


p = Person("Meera", 27, Address("Pune", "411001"), ["staff"])

encoded = json.dumps(asdict(p), indent=2)
print(encoded)

data = json.loads(encoded)
restored = Person(
    name=data["name"],
    age=data["age"],
    address=Address(**data["address"]),
    tags=data["tags"],
)
print(restored == p)          # True

Inheritance

from dataclasses import dataclass, field


@dataclass
class Employee:
    name: str
    salary: float


@dataclass
class Manager(Employee):
    reports: list = field(default_factory=list)
    bonus: float = 0.0

    def total_pay(self):
        return self.salary + self.bonus


m = Manager("Meera", 90_000, ["arun"], 10_000)
print(m)
print(m.total_pay())
print(isinstance(m, Employee))     # True
# A field without a default cannot follow one that has a default
# @dataclass
# class Broken(Employee):
#     department: str          # TypeError, because Employee has no defaults here
#                              # but the rule bites as soon as a parent field has one


from dataclasses import dataclass


@dataclass(kw_only=True)       # Python 3.10+ removes the ordering problem
class Base:
    a: int = 1


@dataclass(kw_only=True)
class Child(Base):
    b: int                      # fine, because everything is keyword only


print(Child(b=2))

Choosing between the options

UseWhen
dictThe shape is unknown or comes from outside
namedtupleA small immutable record, tuple behaviour wanted
@dataclassA record with a known shape, possibly with methods
@dataclass(frozen=True)Immutable, hashable, usable as a key
A plain classBehaviour matters more than the data
from dataclasses import dataclass
from collections import namedtuple

PointTuple = namedtuple("PointTuple", "x y")


@dataclass(frozen=True)
class PointData:
    x: int
    y: int

    def distance_from_origin(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5


t = PointTuple(3, 4)
d = PointData(3, 4)

print(t.x, d.x)
print(t == PointTuple(3, 4), d == PointData(3, 4))
print(d.distance_from_origin())          # methods are easy to add
print(t[0])                              # a namedtuple is also a tuple
# print(d[0])                            # a dataclass is not

A worked example

from dataclasses import dataclass, field, asdict
from datetime import date
from enum import Enum


class Status(Enum):
    DRAFT = "draft"
    PUBLISHED = "published"
    ARCHIVED = "archived"


@dataclass(order=True)
class Note:
    # sort_index is first so ordering uses it, and it is excluded from repr
    sort_index: int = field(init=False, repr=False)
    title: str
    body: str = ""
    status: Status = Status.DRAFT
    tags: list = field(default_factory=list)
    created_on: date = field(default_factory=date.today)
    views: int = field(default=0, compare=False)

    def __post_init__(self):
        if not self.title.strip():
            raise ValueError("a note must have a title")
        self.title = self.title.strip()
        self.sort_index = -self.views

    @property
    def is_visible(self):
        return self.status is Status.PUBLISHED

    @property
    def summary(self):
        return self.body[:40] + ("..." if len(self.body) > 40 else "")

    def publish(self):
        self.status = Status.PUBLISHED
        return self


notes = [
    Note("Python generators", "Generators produce values lazily.", views=120),
    Note("Decorators", "A decorator wraps a function.", views=340),
    Note("  Context managers  ", "with guarantees cleanup."),
]

notes[0].publish()
notes[1].publish()

for note in sorted(notes):
    flag = "*" if note.is_visible else " "
    print(f"{flag} {note.title:<24}{note.views:>5}  {note.summary}")

print()
print(asdict(notes[0])["title"])

Common mistakes

  • Forgetting the type annotation, so the attribute is not a field at all.
  • Using a mutable default instead of default_factory.
  • Putting a field without a default after one with a default.
  • Expecting a non-frozen dataclass to be hashable; it is not.
  • Doing validation in __init__, which the decorator generates - use __post_init__.
  • Using order=True without checking that the field order gives the comparison you want.
  • Assuming frozen=True protects a list field; the list can still be mutated.

Best practices

  • Annotate every field; use field(default_factory=...) for mutable defaults.
  • Use frozen=True whenever the record should not change after creation.
  • Use slots=True for classes created in large numbers.
  • Validate and derive in __post_init__.
  • Use field(repr=False) for secrets and compare=False for volatile fields.
  • Use asdict at the boundary when converting to JSON.

Practice

  1. Convert a class with a hand written __init__, __repr__ and __eq__ into a dataclass.
  2. Write a frozen, ordered dataclass and use instances as dictionary keys.
  3. Add validation and a computed field using __post_init__.
  4. Show the error for a mutable default and fix it with default_factory.
  5. Round trip a nested dataclass through JSON and prove equality is preserved.

Conclusion

@dataclass writes the boilerplate for any class whose job is to hold data, and it refuses the mutable default bug outright. Annotate the fields, use default_factory for containers, validate in __post_init__, and freeze it when it should not change.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Python notes →
Python

Trees and Graphs

A tree is a graph with no cycles and one root. Both are walked with the same two strategies - depth first with a stack, breadth first with a queue.

Read more
Python

Sorting Algorithms

Python sorts for you in n log n. Implementing bubble, insertion, merge and quick sort is still worth doing, because it teaches how algorithms are comp...

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.