Command Palette

Search for a command to run...

Hectal
PHASE 8Intermediate ~28 min· topic 8 of 12

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 @dataclass that 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 : type after 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.

main.pywhole filepython
from dataclasses import dataclass


@dataclass
class Book:
    title: str
    pages: int
    in_stock: bool = True

03Mutable 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.

main.pywhole filepython
    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. 1

    Predict the repr

    Before running the first example, write down what print(b1) shows. Then run it and compare. Notice the keyword form title='Python': the generated repr names every field.

  2. 2

    Provoke the mutable-default error

    Add colours: list = [] to Book and run. Read the ValueError and fix it with field(default_factory=list).

  3. 3

    Freeze it

    Make Book frozen with @dataclass(frozen=True) and try b1.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

Defaults, post-init, asdict, frozen and order New tab

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.

Sign in to run this example in your browser.

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'
Messages and tool results as dataclasses, versus NamedTuple Python 3.10+ New tab

slots=True and kw_only=True need Python 3.10 or later. ChatMessage and ToolResult mirror the shape of real chat-API data.

Sign in to run this example in your browser.

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)
What @dataclass writes for you (roughly)python

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)
Decorator at class-creation timediagram
Rendering diagram…

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.

main.pywhole filepython
from dataclasses import dataclass


@dataclass
class Basket:
    items: list = []
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
...
ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory

Break #2

Put a required field after a default one

Declare name: str = "none" before price: int.

main.pywhole filepython
from dataclasses import dataclass


@dataclass
class Item:
    name: str = "none"
    price: int
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
...
TypeError: non-default argument 'price' follows default argument 'name'

Myth 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.

  • ▸

    @dataclass builds the methods as strings and runs exec on them at class-creation time. That is why the __init__ signature shows the real field names in tracebacks and help(). The result is ordinary Python, so there is no runtime cost compared with hand-written code.

  • ▸

    slots=True creates 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 and super() tricks.

  • ▸

    Use InitVar for values needed only by __post_init__, and field(init=False) or field(repr=False) for computed or sensitive fields. Keep secrets such as API keys out of repr, 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, dict and set defaults. From 3.11 it rejects any unhashable default, so inner: Inner = Inner() (a normal dataclass, which is unhashable) is also a ValueError. Use default_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=True to escape). In a frozen dataclass, __post_init__ cannot assign normally; computed fields use object.__setattr__(self, "d", value). A frozen class cannot inherit from a non-frozen dataclass or the reverse.

  • ▸

    ClassVar annotations are skipped by @dataclass, so count: ClassVar[int] = 0 stays a shared class attribute (Topic 8.2) and is not a field. asdict deep-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. 1

    Many classes only hold data, yet writing __init__, __repr__ and __eq__ by hand is repetitive and easy to get wrong. The @dataclass decorator (from the dataclasses module; Topic 8.3 introduced decorators) generates them from your field list. A field is written as name: 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. 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 a ValueError (the shared-list bug from Topic 8.2). Use field(default_factory=list) instead: it calls list() for each new object.

  3. 3

    __post_init__ runs right after the generated __init__. Use it for validation (raise ValueError for 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, and fields(Class) lists the fields.

  4. 4

    Options go inside @dataclass(...). frozen=True makes 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=True generates <, <=, >, >= that compare fields in order, like tuples. A plain dataclass has eq=True but no hash (unhashable), because it is mutable.

  5. 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, so ToolResult(tool="search") is required and ToolResult("search") raises TypeError. That prevents mixing up positions in classes with many fields, and lets a subclass add required fields after parent fields with defaults.

  6. 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. 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

01

What does @dataclass generate, and what does it save you from writing?

02

Why can't you write items: list = [] in a dataclass, and what is the fix?

03

What do frozen=True and order=True do?

04

When would you choose a NamedTuple over a dataclass, and the reverse?

Practice

01

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.

02

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.

03

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=True makes values safe to share and use as keys, but every change needs replace() 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_factory and __post_init__.

  • Done when you can explain why mutable defaults are rejected and what to use.

  • Done when you can choose between frozen, order, slots and kw_only and say which need Python 3.10.

  • Done when you can use asdict, replace and fields.

  • Done when you can decide between a dataclass and a NamedTuple for a given record.