Advanced · Lesson 57 of 94

dataclasses and enums

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

The previous lesson showed how much code a well-behaved value class needs: __init__ assigning every field, a __repr__, an __eq__, a __hash__. That boilerplate is mechanical, and dataclasses generates it from a list of annotated fields. Write the class body as name: type lines, decorate with @dataclass, and the methods appear. The generated code is ordinary Python you could have written; the decorator saves you from writing it, and from getting it subtly wrong.

dataclasses and enums: @dataclass; class Money:; amount: float; currency: str = 'EUR' → __init__(self, amount, currency='EUR') → __repr__ → Money(amount=5, currency='EUR') → __eq__ by fields
dataclasses and enums · Figure 1 · Open for full size

Fields can have defaults, and the rules follow function parameters: fields with defaults come after fields without. A mutable default like [] is refused, for the same shared-list reason as function defaults; write field(default_factory=list) instead, which calls list() for each instance. frozen=True makes instances immutable, assignment raises, and makes them hashable from their fields, so a frozen dataclass is the right way to write a value object that goes in sets and dictionaries. order=True generates the comparison methods so instances sort by their fields in order.

Option / helper Effect
field(default_factory=list) a fresh list per instance
frozen=True immutable and hashable
order=True <, <=, >, >= comparing fields in declaration order
slots=True less memory, faster attribute access, no ad-hoc attributes
kw_only=True all fields must be passed by keyword
__post_init__(self) runs after the generated __init__; the place to validate
asdict(obj) / astuple(obj) convert to plain data, recursively
replace(obj, field=value) a copy with some fields changed

Validation goes in __post_init__, which the generated __init__ calls last; raise there if a field is out of range. asdict turns an instance into a nested dictionary, which is the bridge to JSON, and replace gives you a modified copy of a frozen instance. Dataclasses are for classes that are mostly data with a little behavior; a class whose identity is its methods and whose state is private is still an ordinary class.

An Enum is a set of named constants: Status.PAID, Status.REFUNDED. Compared with strings, an enum cannot be misspelled without an AttributeError, has a fixed and discoverable set of members, and is its own type so Status.PAID == "paid" is False and a type checker can see the difference. Members compare by identity, work as dictionary keys, and iterate in definition order. StrEnum and IntEnum make members also behave as their underlying string or integer for the cases where you need to serialize them.

dataclasses and enums: status = 'piad'  (typo, silently wrong) → bugs found at runtime, maybe → Status.PIAD  (typo) → AttributeError immediately → Status.PAID → one of a fixed set, type-checked, iterable
dataclasses and enums · Figure 2 · Open for full size

Code

A dataclass and the methods it generates, next to what a hand-written version needed. Defaults, repr, and equality all come free.

# basics.py
from dataclasses import dataclass, field


@dataclass
class Money:
    amount: float
    currency: str = "EUR"


@dataclass
class Order:
    order_id: int
    lines: list = field(default_factory=list)
    note: str = ""


m = Money(12.5)
print(m)
print(m == Money(12.5, "EUR"), m == Money(12.5, "USD"))
o1, o2 = Order(1), Order(2)
o1.lines.append("pen")
print(o1, o2.lines)
print(Order.__init__.__doc__ is None, hasattr(Order, "__eq__"))
Output
Money(amount=12.5, currency='EUR')
True False
Order(order_id=1, lines=['pen'], note='') []
True True

Frozen for hashable values, ordering for sorting, and __post_init__ for validation. Assigning to a frozen instance raises; a negative amount is rejected at construction.

# frozen_order.py
from dataclasses import dataclass


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

    def __post_init__(self):
        if min(self.major, self.minor, self.patch) < 0:
            raise ValueError("version parts must be non-negative")


versions = [Version(3, 14, 7), Version(3, 12), Version(3, 14, 1)]
print(sorted(versions))
print(max(versions), Version(3, 12) < Version(3, 14))
print(len({Version(1), Version(1), Version(2)}))

try:
    Version(1, -1)
except ValueError as err:
    print("rejected:", err)

v = Version(1)
v.major = 2
Output
[Version(major=3, minor=12, patch=0), Version(major=3, minor=14, patch=1), Version(major=3, minor=14, patch=7)]
Version(major=3, minor=14, patch=7) True
2
rejected: version parts must be non-negative
Traceback (most recent call last):
  File "frozen_order.py", line 26, in <module>
    v.major = 2
    ^^^^^^^
  File "<string>", line 34, in __setattr__
dataclasses.FrozenInstanceError: cannot assign to field 'major'

Converting to plain data and back, and modifying a frozen instance with replace. asdict recurses into nested dataclasses, which is the path to JSON.

# convert.py
import json
from dataclasses import asdict, dataclass, field, replace


@dataclass(frozen=True)
class Money:
    amount: float
    currency: str = "EUR"


@dataclass
class Invoice:
    number: int
    total: Money
    tags: list[str] = field(default_factory=list)


inv = Invoice(7, Money(99.5), ["urgent"])
data = asdict(inv)
print(data)
print(json.dumps(data))

restored = Invoice(data["number"], Money(**data["total"]), data["tags"])
print(restored == inv)
print(replace(inv.total, currency="USD"), inv.total)
Output
{'number': 7, 'total': {'amount': 99.5, 'currency': 'EUR'}, 'tags': ['urgent']}
{"number": 7, "total": {"amount": 99.5, "currency": "EUR"}, "tags": ["urgent"]}
True
Money(amount=99.5, currency='USD') Money(amount=99.5, currency='EUR')

Enums: members, identity, iteration, lookup by value, and a StrEnum that serializes cleanly. A typo is an error, not a silent bug.

# enums.py
from enum import Enum, StrEnum, auto


class Status(Enum):
    PENDING = auto()
    PAID = auto()
    REFUNDED = auto()


class Level(StrEnum):
    INFO = "info"
    ERROR = "error"


s = Status.PAID
print(s, s.name, s.value)
print(s is Status.PAID, s == "PAID", Status["REFUNDED"], Status(1))
print([member.name for member in Status])
print(Level.ERROR == "error", f"level={Level.ERROR}", Level("info"))

counts = {Status.PAID: 2, Status.PENDING: 1}
print(counts[Status.PAID])
try:
    Status.PIAD
except AttributeError as err:
    print("typo caught immediately:", err)
Output
Status.PAID PAID 2
True False Status.REFUNDED Status.PENDING
['PENDING', 'PAID', 'REFUNDED']
True level=error info
2
typo caught immediately: type object 'Status' has no attribute 'PIAD'

Try it yourself

Create library.py with a frozen, ordered Isbn dataclass wrapping a string with validation in __post_init__, a Book dataclass with title, author, Isbn, a Genre enum, and a tags list with a default factory. Create four books, sort them by title using a key, group them by genre into a dictionary keyed by the enum, dump one to JSON via asdict (converting the enum to its name), and confirm two books with the same fields are equal.

Common mistakes

What's next

Your data has shape now. Next you will take it apart with structural pattern matching, which reads dataclasses, enums, and nested data as patterns.

Course overview