Addaly is in open beta. Things will change, and AI answers can be wrong — check anything that matters.

Python, From Zero, For AI

From your first line of code to your first API call.

Lesson 61 of 898 min

Dataclasses: the class that is mostly data, written in four lines

The boilerplate

A class that mainly holds data needs the same three methods every time:

python
class Usage:
    def __init__(self, input_tokens, output_tokens, model):
        self.input_tokens = input_tokens
        self.output_tokens = output_tokens
        self.model = model

    def __repr__(self):
        return f"Usage(input_tokens={self.input_tokens!r}, output_tokens={self.output_tokens!r}, model={self.model!r})"

    def __eq__(self, other):
        return (self.input_tokens, self.output_tokens, self.model) == \
               (other.input_tokens, other.output_tokens, other.model)

Fifteen lines, and every one of them can be derived from the three field names. dataclasses derives them:

python
from dataclasses import dataclass

@dataclass
class Usage:
    input_tokens: int
    output_tokens: int
    model: str

Same __init__, same __repr__, same __eq__, generated at class-definition time from the annotated fields. The type annotations are what mark a line as a field; they are not checked at runtime, exactly as module 3 said of hints in general.

python
u = Usage(2000, 500, "gpt-4o-mini")
print(u)                                   # Usage(input_tokens=2000, output_tokens=500, model='gpt-4o-mini')
print(u == Usage(2000, 500, "gpt-4o-mini"))  # True

You can still add methods. A dataclass is an ordinary class with some methods written for you:

python
    def cost(self, prices):
        p = prices[self.model]
        return (self.input_tokens * p["in"] + self.output_tokens * p["out"]) / 1e6

Defaults, and the mutable one

Fields with defaults go after fields without, as with function arguments:

python
@dataclass
class Job:
    model: str
    max_tokens: int = 500
    temperature: float = 0.0

Now the list:

python
@dataclass
class Job:
    model: str
    prompts: list[str] = []        # ValueError at class definition

The dataclass refuses. It is protecting you from the shared-list trap of the previous lesson: [] evaluated once at definition time would become the default for every instance. The fix is a factory, a function called once per instance:

python
from dataclasses import field

@dataclass
class Job:
    model: str
    prompts: list[str] = field(default_factory=list)
    options: dict = field(default_factory=dict)

default_factory=list calls list() for each new Job. Any zero-argument callable works, including a lambda that builds something more elaborate.

Frozen

python
@dataclass(frozen=True)
class ModelSpec:
    name: str
    context_window: int
    price_in: float
    price_out: float

Assigning to a field of a frozen instance raises FrozenInstanceError. Two things follow. A frozen dataclass is hashable, so it can be a dict key or a set member — a useful property for a cache keyed on (model, prompt). And it is safe to share: a function that receives a ModelSpec cannot accidentally change it for everyone else.

Configuration objects want to be frozen. Anything that is a record of a fact — a usage figure, a price at a date — wants to be frozen. Things that change over their life, like a Conversation, do not.

Ordering and other options

@dataclass(order=True) generates <, <= and the rest, comparing field by field in declaration order, so a list of instances sorts without a key. kw_only=True makes every field keyword-only, which prevents the bug where two int fields get swapped by position. slots=True stores fields in a fixed structure instead of a per-instance dict, which halves memory for a million small objects and makes attribute access slightly faster.

To and from dicts

python
from dataclasses import asdict

json.dumps(asdict(u))                  # '{"input_tokens": 2000, ...}'
Usage(**json.loads(text))              # back again

asdict recurses into nested dataclasses, lists and dicts. The ** unpacking on the way back works as long as the JSON keys match the field names exactly and nothing extra is present; an unexpected key raises TypeError. That strictness is a feature when reading your own files, and a nuisance when reading someone else's, which is where the next paragraph comes in.

When to reach for pydantic instead

A dataclass trusts its inputs. Usage("2000", None, 42) constructs happily; the types are decoration. When the data comes from outside — a file someone else wrote, a model's reply, a web request — you want validation, and that is pydantic's BaseModel, which you met in module 6. It looks almost identical:

python
class Usage(BaseModel):
    input_tokens: int
    output_tokens: int
    model: str

but Usage(input_tokens="2000", ...) coerces to 2000, and Usage(input_tokens="lots", ...) raises with a message. The rule: dataclasses for your own internal objects, pydantic at the boundary where untrusted data enters. Do not put pydantic on everything; validation costs time, and inside your own code the types are already right.

NamedTuple is a third option — a tuple with names, immutable, hashable, unpackable — and is the lightest of the three when a function needs to return two or three values with names. TypedDict is a fourth: it is a dict with declared keys, for when the data must stay a dict because a library wants one.

Try this now

Turn the Budget class from module 6 into a dataclass with cap_usd: float, spent: float = 0.0 and calls: int = 0, keeping the charge method. Then make a frozen ModelSpec, put two of them in a set, and try to change one.

The one thing to keep

A dataclass writes __init__, __repr__ and __eq__ from the field list; a mutable default must go through field(default_factory=...), frozen=True makes instances hashable and immutable, and asdict is the road to JSON.

Before you move on

A developer declares `@dataclass class Job: prompts: list = []` and Python refuses to define the class with `ValueError: mutable default <class 'list'> for field prompts is not allowed`. What is the dataclass protecting them from?

Pick the one you would defend. Nobody sees your answer.

No ads. No data sale. No public scores on people. Ever.

© 2026 Addaly