Part 2 · Intermediate Python · Module 13
Dataclasses, Enums & Records
Much of a real program is records: a user, an order line, a configuration, a status code. Python gives you purpose-built tools for them that write the repetitive dunder methods from M11 for you and make invalid states harder to represent.
- Define dataclasses with fields, defaults,
default_factory, and__post_init__validation - Choose dataclass options:
frozen,order,slots,kw_only - Transform records with
replace,asdict, andastuple - Model fixed sets of choices with
Enum,IntEnum,StrEnum,Flag, andauto - Use
NamedTupleandTypedDict, and pick the right record type for a job - Preview Python 3.15's
frozendict(PEP 814) andsentinel(PEP 661)
The boilerplate problem
A record is an object whose main job is to hold a few named fields together. Writing one by hand, properly, means writing an initializer, a __repr__, and __eq__; if you want ordering or hashing, more again. Every field name is typed four or five times, and adding a field later means editing every method. Step through a hand-written record and count how much of it is pure ceremony:
That class is roughly what the @dataclass decorator generates from three annotated lines. The decorator reads the class's type annotations (the name: type lines), treats each as a field, and writes the methods for you:
from dataclasses import dataclass
@dataclass
class Product:
sku: str
name: str
price: float = 0.0
Same behavior, a quarter of the code, and impossible to get out of sync when a field is added.
Remember: records are everywhere; write them with tools that generate the boilerplate.
@dataclass basics
A dataclass is an ordinary class, decorated with @dataclass from the standard library dataclasses module. You can still add methods, properties, and class attributes. By default the decorator generates __init__, __repr__, and __eq__ (comparing fields in order, and only with instances of the same class).
A few rules worth knowing:
- Annotations are required. A class attribute without an annotation (
count = 0) is not a field; it stays an ordinary class attribute. - Annotations are not enforced.
Point("a", "b")works at runtime. Type hints are for readers and type checkers like mypy (M16); validate in__post_init__if you need runtime checks. - Defaults go last. A non-default field after a default one is a
TypeErrorat class creation, for the same reason as function parameters.kw_only(below) lifts this restriction. - Use
ClassVarfor shared constants that are annotated but shouldn't be fields:MAX: ClassVar[int] = 10.
Remember: annotated class-body names become fields; the decorator writes __init__, __repr__, and __eq__; hints are not enforced at runtime.
Mutable defaults, default_factory, and __post_init__
You learned the mutable-default trap with functions (M05) and class attributes (M10). Dataclasses protect you: a list, dict, or set default raises ValueError when the class is created. Use field(default_factory=list) to call a factory function for each new instance:
field() customizes one field. Its most useful options:
| Option | Effect |
|---|---|
default= | A plain default value (same as = value) |
default_factory= | A zero-argument callable called for each new instance |
init=False | Not an __init__ parameter; set it in __post_init__ |
repr=False | Hide from the repr (for secrets or huge values) |
compare=False | Ignore in __eq__ and ordering (and hashing) |
kw_only=True | This field must be passed by keyword |
Validation and derived fields with __post_init__
The generated __init__ just assigns fields. If you define __post_init__(self), it is called right after, which is the place to validate inputs, normalize values, and compute derived fields marked init=False:
Remember: use field(default_factory=...) for mutable defaults and __post_init__ to validate and compute.
Options: frozen, order, slots, kw_only
The decorator takes keyword arguments that switch on more generated behavior. You will combine them constantly.
frozen=True: immutable records
A frozen dataclass raises dataclasses.FrozenInstanceError (a subclass of AttributeError) on any attempt to assign a field. Because its fields can't change, it also gets a generated __hash__, so instances work as dict keys and set members. Recall from M11: a regular (non-frozen) dataclass with eq=True has __hash__ = None, because a mutable object must not be hashed by value.
order=True: comparisons
Generates <, <=, >, and >= that compare fields as a tuple, in definition order. Field order therefore becomes part of your API: put the most significant field first, and use field(compare=False) to exclude the rest.
slots=True: compact, typo-proof instances
Normally every instance carries a __dict__. With slots=True the decorator generates __slots__, storing fields in fixed positions. Instances use less memory, attribute access is slightly faster, and assigning a misspelled attribute raises AttributeError instead of silently creating it.
kw_only=True: keyword-only construction
Every field must be passed by name. For records with many fields of the same type, this prevents mixing up positions (Rect(3, 4): is that width, height?) and also lifts the "defaults last" rule. You can also make only some fields keyword-only: everything after a _: KW_ONLY pseudo-field is keyword-only.
Remember: frozen for immutability and hashing, order for tuple-style comparison, slots for compact typo-proof instances, kw_only for clarity.
Working with records: replace, asdict, astuple
Immutable records are "changed" by making a modified copy. dataclasses.replace(obj, **changes) builds a new instance with some fields replaced (it calls __init__, so __post_init__ validation runs again). Since Python 3.13, the generic copy.replace() does the same for dataclasses, named tuples, and other types that support it.
asdict() and astuple() convert a dataclass, recursively, into plain dicts and tuples, which is exactly what you need for JSON serialization.
Remember: replace makes a modified copy (re-running validation); asdict/astuple convert recursively for serialization.
Enums: a fixed set of named choices
Programs are full of values drawn from a small, fixed set: order statuses, log levels, directions, card suits. Using raw strings ("shipped") invites typos that fail silently ("shiped"), and using magic numbers (status == 3) is unreadable. An enumeration (enum.Enum) defines the set once; each member is a unique, named singleton with a .name and a .value.
Enums are classes, so they can have methods and properties. Members are compared by identity (is works), they are hashable, and you can't create new members or reassign them at runtime. The @unique decorator rejects accidental duplicate values (otherwise a duplicate becomes an alias for the first member).
IntEnum, StrEnum, and Flag
IntEnummembers are alsoints: they compare equal to and sort with integers. Useful for interoperating with code that expects numbers (HTTP status codes,http.HTTPStatusis one).StrEnum(Python 3.11+) members are alsostrs; withauto()the value is the lowercased member name. Great for values that appear in JSON, config files, or CLI arguments.Flagmembers can be combined with|into sets of options;auto()assigns powers of two (1, 2, 4, ...), so each member is one bit (see the bits visualizer below).
The bits view shows READ | WRITE = 0b011 = 3: each flag is one bit, and | sets bits, & tests them.
Remember: an Enum is a closed set of unique, named members; use auto(), look up by Cls(value) or Cls["NAME"], and pick StrEnum/IntEnum/Flag only for their extra behavior.
NamedTuple and TypedDict
typing.NamedTuple: a tuple with field names
A named tuple is a tuple: immutable, indexable, unpackable, and compact, with readable field names on top. It's ideal for small fixed records returned from functions, especially where code already expects tuples (for example, x, y = point).
typing.TypedDict: a typed shape for dicts
Sometimes the data must stay a dict: JSON from an API, rows from a library, keyword arguments. TypedDict describes the expected keys and value types for the type checker. At runtime the object is a plain dict, and nothing is checked.
Remember: NamedTuple is an immutable tuple with names; TypedDict is a static description of a dict's keys, with no runtime checking.
When to use which
You now have six ways to hold a record. They overlap, but each has a sweet spot.
| Tool | Mutable? | Hashable? | Runtime type | Best for |
|---|---|---|---|---|
dict | Yes | No | dict | Dynamic keys, quick scripts, data you just pass through |
TypedDict | Yes | No | dict | Typing JSON-like data that must stay a dict |
NamedTuple | No | Yes (if fields are) | tuple subclass | Small immutable records, function results, tuple compatibility |
@dataclass | Yes | No (by default) | your class | Domain objects with behavior and state that changes |
@dataclass(frozen=True) | No | Yes | your class | Value objects, dict keys, config, the safest default |
| Hand-written class | You decide | You decide | your class | Rich invariants, encapsulated state (M10's BankAccount) |
Enum | No | Yes | your enum | A closed set of choices, not a record |
One reasonable set of answers
(1) NamedTuple or a frozen dataclass. (2) A mutable dataclass, or a class with methods that enforce movement rules. (3) @dataclass(frozen=True, slots=True). (4) StrEnum, so it reads from and writes to text naturally. (5) TypedDict at the boundary, converted into a frozen dataclass for the rest of the program.
Remember: default to a frozen dataclass for records, an Enum for choices, a NamedTuple when tuple behavior helps, and a TypedDict when it must stay a dict.
Preview: frozendict and sentinel in Python 3.15
frozendict (PEP 814)
A frozen dataclass is only as immutable as its fields. Until now, a mapping field forced a choice: store a dict (mutable, unhashable) or work around it with types.MappingProxyType (a read-only view of a dict that can still change underneath) or a tuple of pairs. PEP 814 adds frozendict, a built-in immutable mapping. It supports all of a dict's reading operations, can't be modified, and is hashable when its values are, so it can live inside frozen records and sets.
sentinel (PEP 661)
A sentinel is a unique marker value meaning "nothing was given", for cases where None is itself a legitimate value. The traditional idiom is _MISSING = object(), which works but has an unhelpful repr (<object object at 0x...>) in error messages and signatures. PEP 661 adds a sentinel built-in for creating named sentinels with a clear repr:
In 3.14, keep using the object() idiom (or a single-member Enum, which has a nice repr and works with type checkers):
Remember: 3.15's frozendict completes the immutable-record toolkit; sentinel standardizes "no value given" markers. Compare sentinels with is.
Practice
Convert this hand-written class into a @dataclass named Book with fields title: str, author: str, pages: int = 0. Keep the is_long() method (more than 400 pages).
Hint
Replace __init__ with three annotated lines under @dataclass.
Show solution
from dataclasses import dataclass
@dataclass
class Book:
title: str
author: str
pages: int = 0
def is_long(self):
return self.pages > 400
Write a dataclass Team with name: str, members: list[str] defaulting to a new empty list, and size: int that is not an initializer argument. In __post_init__: strip the name and raise ValueError if it is empty; remove duplicate members while keeping first-seen order; set size to the number of members.
Hint
members: list[str] = field(default_factory=list) and size: int = field(init=False). list(dict.fromkeys(items)) de-duplicates while keeping order.
Show solution
from dataclasses import dataclass, field
@dataclass
class Team:
name: str
members: list[str] = field(default_factory=list)
size: int = field(init=False)
def __post_init__(self):
self.name = self.name.strip()
if not self.name:
raise ValueError("team name must not be blank")
self.members = list(dict.fromkeys(self.members))
self.size = len(self.members)
Write @dataclass(frozen=True, order=True, slots=True) Version with major, minor, patch (ints, defaults 0 for minor and patch). Add a classmethod parse("1.2.3") (missing parts default to 0, so "2" is 2.0.0), __str__ returning "1.2.3", and bump(part) that returns a new Version using dataclasses.replace: bumping "minor" increments minor and resets patch to 0; "major" resets both; "patch" increments patch. Unknown parts raise ValueError.
Hint
In parse: nums = [int(p) for p in text.split(".")], then cls(*nums). In bump, branch on part and return replace(self, ...).
Show solution
from dataclasses import dataclass, replace
@dataclass(frozen=True, order=True, slots=True)
class Version:
major: int
minor: int = 0
patch: int = 0
@classmethod
def parse(cls, text):
return cls(*(int(p) for p in text.split(".")))
def __str__(self):
return f"{self.major}.{self.minor}.{self.patch}"
def bump(self, part):
match part:
case "major":
return replace(self, major=self.major + 1, minor=0, patch=0)
case "minor":
return replace(self, minor=self.minor + 1, patch=0)
case "patch":
return replace(self, patch=self.patch + 1)
case _:
raise ValueError(f"unknown part: {part!r}")
Write an Enum Light with members RED, GREEN, YELLOW (in that order, values via auto()). Add a method next() returning the following light in the cycle RED → GREEN → YELLOW → RED, and a property can_go that is True only for GREEN. Also write a StrEnum Mode with AUTO and MANUAL (values "auto", "manual" via auto()).
Hint
members = list(type(self)), then use members.index(self) and modulo to wrap around.
Show solution
from enum import Enum, StrEnum, auto
class Light(Enum):
RED = auto()
GREEN = auto()
YELLOW = auto()
def next(self):
members = list(type(self))
return members[(members.index(self) + 1) % len(members)]
@property
def can_go(self):
return self is Light.GREEN
class Mode(StrEnum):
AUTO = auto()
MANUAL = auto()
Write a Flag called Perm with R, W, X (using auto()). Then write to_text(perm) returning a Unix-style string such as "rw-" or "r-x", and from_text(text) doing the reverse ("---" gives Perm(0)).
Hint
Pair letters with members: zip("rwx", [Perm.R, Perm.W, Perm.X]). Test with flag in perm; build with perm |= flag starting from Perm(0).
Show solution
from enum import Flag, auto
class Perm(Flag):
R = auto()
W = auto()
X = auto()
_LETTERS = list(zip("rwx", [Perm.R, Perm.W, Perm.X]))
def to_text(perm):
return "".join(letter if flag in perm else "-" for letter, flag in _LETTERS)
def from_text(text):
perm = Perm(0)
for (letter, flag), char in zip(_LETTERS, text):
if char == letter:
perm |= flag
return perm
Define a NamedTuple Reading with sensor: str, value: float, and unit: str = "C". Write parse_line(line) that turns "t1, 21.5" or "t1, 70.0, F" into a Reading (strip spaces, convert the value to float), and by_sensor(readings) returning a dict mapping each sensor to the list of its values in input order.
Hint
parts = [p.strip() for p in line.split(",")], then Reading(parts[0], float(parts[1]), *parts[2:]). Use dict.setdefault for grouping.
Show solution
from typing import NamedTuple
class Reading(NamedTuple):
sensor: str
value: float
unit: str = "C"
def parse_line(line):
parts = [p.strip() for p in line.split(",")]
return Reading(parts[0], float(parts[1]), *parts[2:3])
def by_sensor(readings):
groups = {}
for r in readings:
groups.setdefault(r.sensor, []).append(r.value)
return groups
Mini-project: an inventory model
Quiz
Recap
@dataclassturns annotated class attributes into fields and generates__init__,__repr__,__eq__.- Mutable defaults need
field(default_factory=...); validate and derive in__post_init__. frozen=True(immutable, hashable),order=True(tuple comparison),slots=True(compact, typo-proof),kw_only=True(named arguments).replacecopies with changes;asdict/astupleconvert recursively.Enumdefines a closed set of named members;auto(),Cls(value),Cls["NAME"];IntEnum,StrEnum,Flagadd int, str, and bitwise behavior.NamedTuple: immutable tuple with names.TypedDict: static description of a dict.- Default to a frozen dataclass for records and an Enum for choices.
- Python 3.15 previews:
frozendictandsentinel.