Topic 8.8
Dataclasses
In one line
A dataclass is a class for holding data. The @dataclass decorator reads the annotated fields you list and writes __init__, __repr__ and __eq__ for you, with options for defaults, immutability (frozen), ordering, slots and keyword-only fields.
Think of it like this
A form with labelled boxes. A school admission form has boxes for Name, Age and Class. You do not describe how to read the form; you just list the boxes. A dataclass is that form: you list the fields, and Python writes the rest of the boilerplate (the code for creating, printing and comparing a form).
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Dataclass
- A class made with
@dataclassthat gets__init__,__repr__and__eq__generated from its fields. - Field
- A named piece of data declared as
name: type(optionally with a default). - Type hint (annotation)
- The
: typeafter a name. A label for readers and tools, not enforced by Python (Phase 10). - `default_factory`
- A function called for each new object to build its default value, used for lists, dicts and sets.
- `__post_init__`
- A hook the generated
__init__calls at the end, for validation or computed fields. - Frozen
- An immutable dataclass: changing a field raises
FrozenInstanceError. - Slots
- A fixed-field storage layout (
slots=True, Python 3.10) that saves memory and blocks new attributes. - Keyword-only
- Fields that must be given by name (
kw_only=True, Python 3.10). - NamedTuple
- A tuple subclass with named fields, immutable and unpackable.
Step by step
01The boilerplate problem
A class for a book written by hand needs __init__ with three assignments, a __repr__ that lists three fields, and an __eq__ that compares three fields. Add a fourth field and you must edit all three places. Forgetting one gives a quiet bug.
02List the fields, let @dataclass write the rest
Write @dataclass above the class and one name: type per field. You instantly get a constructor, a readable repr and an == that compares all fields in order.
from dataclasses import dataclass
@dataclass
class Book:
title: str
pages: int
in_stock: bool = True03Mutable defaults need default_factory
tags: list = [] would share one list between all books, so @dataclass refuses it with ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory. Write tags: list = field(default_factory=list). Each new object calls list() and gets its own list, as the example shows.
Also remember: fields without defaults must come before fields with defaults.
04__post_init__ for validation
The generated __init__ assigns the fields and then calls __post_init__ if it exists. Raising ValueError there means a Book with zero pages can never be created. This puts your rule in one place.
def __post_init__(self):
if self.pages <= 0:
raise ValueError("pages must be positive")05Frozen and ordered
@dataclass(frozen=True, order=True) gives immutable, sortable, hashable points. Changing p.x raises an error. Point(1, 2) < Point(1, 3) compares x first, then y. Frozen dataclasses are the right choice for configuration and values used as keys.
A normal dataclass is mutable and has eq=True, so Python sets its __hash__ to None: hash(book) is a TypeError.
06slots and kw_only for message-like classes
@dataclass(slots=True, kw_only=True) suits records with many fields: no per-object dictionary, no accidental new attributes (r.colour = "red" fails), and callers must write ToolResult(tool="search", ok=True) so positions never get mixed up.
Both options need Python 3.10 or later.
07Dataclass versus NamedTuple
A NamedTuple unpacks (x, y = nt), indexes and compares equal to a tuple (nt == (1, 2)), but its fields cannot be changed. A dataclass is a normal class: dc == (1, 2) is False, and dc.x = 99 works unless frozen.
Remember that the type hints are labels. PointDC("a", None) is accepted without complaint; use Pydantic (Phase 16) when you need real checking.
Try it yourself
- 1
Predict the repr
Before running the first example, write down what
print(b1)shows. Then run it and compare. Notice the keyword formtitle='Python': the generatedreprnames every field. - 2
Provoke the mutable-default error
Add
colours: list = []toBookand run. Read theValueErrorand fix it withfield(default_factory=list). - 3
Freeze it
Make
Bookfrozen with@dataclass(frozen=True)and tryb1.tags.append("new"). It still works. Frozen stops you re-assigning a field, not changing a mutable object inside it. For true immutability, use a tuple instead of a list.
Code & diagrams
Change Point to frozen=False and see which lines now fail. Then add a field with a default before one without, and read the error.
Expected output
Book(title='Python', pages=300, tags=[], in_stock=True)
True False
['lang', 'fast'] []
['new'] []
{'title': 'Go', 'pages': 250, 'tags': ['lang', 'fast'], 'in_stock': True}
['title', 'pages', 'tags', 'in_stock']
Book(title='Go', pages=999, tags=['lang', 'fast'], in_stock=True)
ValueError: pages must be positive
TypeError: unhashable type: 'Book'
Point(x=1, y=2) True [Point(x=1, y=5), Point(x=2, y=0)]
1
AttributeError: cannot assign to field 'x'slots=True and kw_only=True need Python 3.10 or later. ChatMessage and ToolResult mirror the shape of real chat-API data.
Expected output
ChatMessage(role='assistant', content='Here you go', tool_results=[ToolResult(tool='search', ok=True, output='3 results', warnings=[])])
{"role": "assistant", "content": "Here you go", "tool_results": [{"tool": "search", "ok": true, "output": "3 results", "warnings": []}]}
TypeError: ToolResult.__init__() takes 1 positional argument but 2 were given
AttributeError: 'ToolResult' object has no attribute 'colour' and no __dict__ for setting new attributes
False
1 2 1 2 True
False 1
PointDC(x=99, y=2)
AttributeError: can't set attribute
PointDC(x='a', y=None)A simplified picture, not the exact generated code. You can see the real source of a generated method by reading the dataclasses module.
# You write:
@dataclass
class Book:
title: str
pages: int
# The decorator adds, roughly:
# def __init__(self, title, pages): self.title = title; self.pages = pages
# def __repr__(self): return f"Book(title={self.title!r}, pages={self.pages!r})"
# def __eq__(self, other): return (self.title, self.pages) == (other.title, other.pages)Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Use a list as a plain default
Write items: list = [] in a dataclass.
from dataclasses import dataclass
@dataclass
class Basket:
items: list = []Break #2
Put a required field after a default one
Declare name: str = "none" before price: int.
from dataclasses import dataclass
@dataclass
class Item:
name: str = "none"
price: intMyth vs fact
Myth
Dataclass type hints are checked when you create an object.
Fact
They are not. PointDC("a", None) works. Type checkers (Phase 10) and Pydantic check types; plain dataclasses do not.
Myth
A frozen dataclass is deeply immutable.
Fact
Frozen only blocks re-assigning fields. A list stored in a frozen dataclass can still be changed in place. Use tuples or frozenset for deep immutability.
Myth
Dataclasses are just NamedTuples with extra steps.
Fact
A dataclass is a regular class: mutable by default, supports inheritance and methods, and does not behave like a tuple. NamedTuple is an actual tuple.
Myth
@dataclass makes objects hashable.
Fact
Only with frozen=True (or unsafe_hash=True). A plain mutable dataclass with eq=True is unhashable.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
@dataclassbuilds the methods as strings and runsexecon them at class-creation time. That is why the__init__signature shows the real field names in tracebacks andhelp(). The result is ordinary Python, so there is no runtime cost compared with hand-written code. - ▸
slots=Truecreates a new class object (it has to, because__slots__must exist when the class is created) and returns that in place of the one you wrote. Code that grabbed a reference to the original class before decoration would see a different object, which is rarely a problem but explains odd bugs with registries andsuper()tricks. - ▸
Use
InitVarfor values needed only by__post_init__, andfield(init=False)orfield(repr=False)for computed or sensitive fields. Keep secrets such as API keys out ofrepr, because reprs end up in logs:api_key: str = field(repr=False). - ▸
Version detail on mutable defaults: before Python 3.11 the check rejected only
list,dictandsetdefaults. From 3.11 it rejects any unhashable default, soinner: Inner = Inner()(a normal dataclass, which is unhashable) is also aValueError. Usedefault_factory=Inner. - ▸
Inheritance and freezing: fields are collected from the base classes first, then the subclass, which is why a base-class default forces defaults on every later field (use
kw_only=Trueto escape). In a frozen dataclass,__post_init__cannot assign normally; computed fields useobject.__setattr__(self, "d", value). A frozen class cannot inherit from a non-frozen dataclass or the reverse. - ▸
ClassVarannotations are skipped by@dataclass, socount: ClassVar[int] = 0stays a shared class attribute (Topic 8.2) and is not a field.asdictdeep-copies values and recurses into lists, dicts and nested dataclasses, so it is slow for big objects and loses types: round trips need your own parsing. - ▸
For data that arrives from outside (JSON from an LLM, a config file, a web form), plain dataclasses will not validate types. That is the exact gap Pydantic fills (Phase 16), so reach for a dataclass for internal values and Pydantic at the boundary of your program.
Remember this
- 1
Many classes only hold data, yet writing
__init__,__repr__and__eq__by hand is repetitive and easy to get wrong. The@dataclassdecorator (from thedataclassesmodule; Topic 8.3 introduced decorators) generates them from your field list. A field is written asname: type. The type is called a type hint; here it is required because Python reads it to find the fields, but Python does not enforce it. Phase 10 teaches type hints fully. - 2
Defaults come after
=. Fields without a default must come before fields with one, like function parameters. For a mutable default such as a list, a plain= []is rejected with aValueError(the shared-list bug from Topic 8.2). Usefield(default_factory=list)instead: it callslist()for each new object. - 3
__post_init__runs right after the generated__init__. Use it for validation (raiseValueErrorfor a bad value) or to compute a field from others. Helpers:asdict(obj)turns an object into a dictionary (and recurses into nested dataclasses),replace(obj, pages=999)makes a modified copy, andfields(Class)lists the fields. - 4
Options go inside
@dataclass(...).frozen=Truemakes instances immutable: assigning to a field raises an error, and Python also generates__hash__(Topic 8.7), so frozen instances work as dictionary keys.order=Truegenerates<,<=,>,>=that compare fields in order, like tuples. A plain dataclass haseq=Truebut no hash (unhashable), because it is mutable. - 5
slots=True(Python 3.10) stores fields in a fixed layout instead of a per-object dictionary: less memory and typo-safe (Topic 8.12).kw_only=True(Python 3.10) makes every field keyword-only, soToolResult(tool="search")is required andToolResult("search")raisesTypeError. That prevents mixing up positions in classes with many fields, and lets a subclass add required fields after parent fields with defaults. - 6
Dataclass versus
NamedTuple: a NamedTuple is a tuple with names, so it is immutable, unpacks (x, y = point), indexes (point[0]) and equals a plain tuple. A dataclass is a normal class: mutable by default, supports methods, inheritance and__post_init__, and does not equal a tuple. Choose NamedTuple for small, fixed records, and a dataclass for most data classes. - 7
In AI engineering, dataclasses are the natural shape of messages, tool calls, tool results and configuration:
ChatMessage(role, content),ToolResult(tool, ok, output). They are also the stepping stone to Pydantic models (Phase 16), which add real validation and JSON parsing on top of the same idea.
Explain it without notes
What does @dataclass generate, and what does it save you from writing?
Why can't you write items: list = [] in a dataclass, and what is the fix?
What do frozen=True and order=True do?
When would you choose a NamedTuple over a dataclass, and the reverse?
Practice
Write a dataclass Student with name: str, marks: list (default empty list) and passed: bool = False. Create two students, add a mark to one and show the other's list is still empty.
Write a frozen, ordered dataclass Version with major, minor, patch ints. Sort [Version(1,2,0), Version(1,10,0), Version(1,2,5)] and print the result.
Write a dataclass Order with price: float and quantity: int and a __post_init__ that raises ValueError if either is not positive, and a computed total field (field(init=False)).
Trade-offs
- ↔
Dataclasses remove boilerplate, but they do not validate. Types are labels; use
__post_init__for simple rules and Pydantic at program boundaries for real validation. - ↔
**
frozen=Truemakes values safe to share and use as keys, but every change needsreplace()to build a new object.** That is cheap for small records and awkward for large mutable state.
Done when you can
Done when you can write a dataclass with defaults,
default_factoryand__post_init__.Done when you can explain why mutable defaults are rejected and what to use.
Done when you can choose between
frozen,order,slotsandkw_onlyand say which need Python 3.10.Done when you can use
asdict,replaceandfields.Done when you can decide between a dataclass and a NamedTuple for a given record.