JJoeven

Curriculum/Python

Dataclasses

Use dataclass to hold data. Freeze a ToolCall snapshot. Set list defaults with field(default_factory=list). Compare with a plain dict.

intermediate20 min26 / 37

A dataclass is a short way to make a class that mainly holds data. Python writes __init__ and a useful print form for you. You still have a real class. You can add methods later. Start with fields.

Import it from the standard library:

python
from dataclasses import dataclass

Put @dataclass on the line above the class. List fields with types. That is the record. @dataclass is a decorator: a line that wraps the class and fills in boilerplate. You do not need to write other decorators in this track.

Use a dataclass when the object is a record: a tool call, a run state, a case in an eval file. Use a normal class when behavior is the point and data is leftover. Use a dict when the shape is still JSON you do not trust.

A record with named fields
namequeryToolCall

A dataclass is a short class for data. Freeze a snapshot you must not edit.

A record with named fields

@dataclass

python
from dataclasses import dataclass

@dataclass
class ToolCall:
    name: str
    query: str

Then:

python
call = ToolCall(class="tok-s">"search", class="tok-s">"python lists")
print(call.name)
print(call)

The print form shows field names and values. That is nicer in a trace than <__main__.ToolCall object>. Equality compares fields: two ToolCalls with the same name and query compare equal. Two dicts also compare equal that way, but a typo key in a dict is still “equal to itself” and wrong.

You construct with positional or keyword args: ToolCall(name="search", query="python lists"). Required fields have no default and must be passed.

frozen=True

Frozen means you cannot change the fields after you make the object. That is useful for a ToolCall record: the model asked for a tool, and you want that fact to stay still.

python
@dataclass(frozen=True)
class ToolCall:
    name: str
    query: str

call.name = "read" then fails. The object is a snapshot. The error type is FrozenInstanceError (a dataclass error). Catch Exception in a demo; in real code you simply do not assign.

If a field is a dict, the dict itself can still change. Frozen stops call.name = .... It does not freeze objects inside fields. Keep ToolCall fields simple when you can: strings, numbers, tuples. If you need args, a frozen dataclass with args: tuple is safer than a dict you might mutate. Or copy args when you run the tool.

Do not freeze the live agent state if step must rise each turn. Freeze the request. Mutate the state. That split matches the system: the model asked once; the loop counts many times.

Field defaults

A field can have a default. Use = for numbers and text.

For a new list on every object, use field(default_factory=list). If you write log: list = [], objects may share one list. That bug is hard to see. It is the same shared-default bug as def f(x, log=[]):.

python
from dataclasses import dataclass, field

@dataclass
class State:
    goal: str
    step: int = 0
    log: list = field(default_factory=list)
DefaultUse
step: int = 0Fine. 0 cannot change in place.
log: list = field(default_factory=list)Each object gets its own list.
log: list = []Avoid. Lists can be shared.

Fields without defaults must come first. goal: str then step: int = 0 is legal. The other order is not.

default_factory=list is a function that is called to make a new list. list with no parentheses is the function. Do not write default_factory=list() — that would call it once. The dataclass factory would then be the same mistake in a different coat.

For a dict, default_factory=dict. Never [] or {} as the default value on the field.

vs a plain dict

A dict is flexible. You can add any key. That is also the problem: a typo like nam makes a new key and does not fail.

A dataclass names the fields. call.query is clear. Editors can hint. Equality works field by field.

Use a dict for raw JSON you just loaded. Turn it into a dataclass when the shape is stable. At the edge, isinstance and .get. Inside, call.query.

You can convert with a small helper: ToolCall(name=d["name"], query=d["query"]). If a key is missing, that helper crashes. That is good after you validated.

NeedDictDataclassFrozen dataclass
Raw model JSONyesafter you checkafter you check
Typo should failnoyes (nam is AttributeError)yes
Snapshot of a requestmaybemaybeyes
step that risesyesyesno
json.dumps directlyyesno — convert firstno

json.dumps does not understand your class unless you convert. asdict from dataclasses builds a dict of fields. Convert at the edge. Keep the record inside the loop.

Equality on dataclasses compares fields, not object identity. ToolCall("search", "q") == ToolCall("search", "q") is True even though they are two objects. That is what you want in tests. Frozen plus equality makes a nice set of unique calls if fields are immutable. If args is a dict, it is not hashable in the usual frozen way unless you set unsafe_hash — skip that. Keep frozen records simple.

Walkthrough: JSON to record to JSON

python
from dataclasses import dataclass, asdict

@dataclass
class ToolCall:
    name: str
    query: str

raw = {class="tok-s">"name": class="tok-s">"search", class="tok-s">"query": class="tok-s">"python lists", class="tok-s">"extra": 1}
call = ToolCall(name=raw[class="tok-s">"name"], query=raw[class="tok-s">"query"])
print(call)
print(asdict(call))

extra is dropped on purpose. That is the schema. Unknown keys do not become mystery fields. If you needed to keep extras, you wanted a dict, not a record.

If you add fields later, put them at the end with defaults so old constructors still work: goal, then step=0. Dataclasses are still classes. You can add def as_row(self): that returns a dict for JSON. Prefer an explicit method when the dump shape is not identical to the fields.

You can put a method on the state dataclass: def budget_left(self, max_steps): return max_steps - self.step. If methods grow, you still have a class. The decorator only saved __init__ and printing. A dataclass with ten methods is a hint you wanted a normal class.

What goes wrong

  • log: list = [] on a dataclass.
  • Expecting frozen to freeze inner dicts.
  • Using a dataclass for JSON you have not validated.
  • A dataclass with ten methods — you wanted a normal class.
  • Forgetting to import field.
  • json.dumps(call) without converting.
  • default_factory=list() with parentheses, so every object shares one list.
Live PythonOpen full playgroundpython
Output
Run to execute this in your browser. Nothing is sent to a server.

a.log grew. b.log stayed empty. That is default_factory doing its job. The dict typo nam stayed, which is why snapshots prefer a dataclass. Equal frozen calls compare by fields.

How agents use this

A tool call is a small record: name plus arguments. frozen=True keeps that snapshot still while you run the tool. Agent state (goal, step, log) fits a dataclass with defaults. Raw model JSON can stay a dict until you check it. Then copy the fields into a dataclass so typos fail early.

You can put a method on the state dataclass: def budget_left(self, max_steps): return max_steps - self.step. If methods grow, you still have a class. The decorator only saved __init__ and printing.

Eval cases are records too: input, expected_tool, expected_args. A dataclass makes a missing column an error at construction, not a silent KeyError in the middle of a 200-row file. Build them in a loop from JSONL. If a line fails, skip that case and count it. Do not dump the whole run because one row has a typo — unless you want fail-fast on fixtures. Pick, then test.

When you log, asdict(state) plus json.dumps is the file. When you run, state.step += 1 is the loop. Do not mix: do not freeze state, do not mutate the request. Two types, two jobs. That is the agent-shaped use of dataclasses.

Check your understanding

What does frozen=True do on a dataclass?