Python Dataclasses and Structured State: Designing Explicit Data Contracts
I have watched the same bug arrive in three different codebases. A configuration payload enters as a dictionary, gets copied into a queue message, gets…

Key topics
A dict is a shape you remember. A dataclass is a shape you can point to.
I have watched the same bug arrive in three different codebases. A configuration payload enters as a dictionary, gets copied into a queue message, gets mutated by a retry handler, and surfaces forty minutes later as a wrong model parameter in a log line that names nothing. There is no traceback pointing at the mutation, because nothing was illegal. The dict accepted every write. That is the whole problem.
This article is about replacing that ambiguity with a written contract. By the end, you should be able to look at any structured state in your system and answer three questions without reading the rest of the module: who owns each field, what the default policy is, and where the object becomes or stops being a plain dict.
Why Dictionaries Stop Scaling as State
Dictionaries are not the enemy. They are excellent for short-lived, local, genuinely flexible data — a request body you are about to validate, a cache entry you will drop in ten seconds, a kwargs bag you are forwarding to a function that already declares its parameters.
They fail at a specific moment: when the same shape crosses a module boundary, a process boundary, or a serialization boundary. That is when a dict stops being a convenience and becomes an unwritten contract that every component interprets differently.
The failure modes are quiet, which is what makes them expensive:
- A missing key surfaces at the point of use, not the point of construction, so the error appears far from the cause.
- A typo in a key name becomes a new key.
state["retry_count"]andstate["retrycount"]can coexist, and nothing complains. - Nothing declares which component is allowed to write a field. Every reader is a potential writer.
The hidden question is not "what fields exist." It is "who owns this field, and when is it allowed to change." A dict cannot answer that. It has no place to write the answer down.
If you have internalized that type annotations are documentation-level hints rather than runtime enforcement, and that TypedDict describes a dict's shape without creating a runtime type, you already have the vocabulary for what comes next. A dataclass is the same idea pushed one step further: it creates an actual type, generates the boilerplate, and gives you a place to declare ownership, defaults, and boundaries.
A data contract is only as strong as its declared ownership, default policy, and boundary behavior. Everything else is decoration.
Keep that criterion in mind. Every section below is a test of it.
What @dataclass Actually Generates
The @dataclass decorator reads the class-level annotations and synthesizes methods. It does not create a new runtime type system, and it does not enforce your annotations at runtime. It generates code.
from dataclasses import dataclass, fields
@dataclass
class RunConfig:
model: str
temperature: float = 0.0
max_tokens: int = 512
print(RunConfig.__init__.__qualname__)
for f in fields(RunConfig):
print(f.name, f.type, f.default)
RunConfig.__init__
model <class 'str'> <dataclasses._MISSING_TYPE object at ...>
temperature <class 'float'> 0.0
max_tokens <class 'int'> 512
The generated __init__ is roughly what you would have written by hand:
def __init__(self, model, temperature=0.0, max_tokens=512):
self.model = model
self.temperature = temperature
self.max_tokens = max_tokens
Two consequences matter for the rest of this article.
First, field order in the generated __init__ follows declaration order. With inheritance, the final order is base fields first, then derived fields, with overrides keeping their original position. That means adding a field to a base class silently shifts every positional argument in every derived class.
Second, because generation happens at class creation, changing a field's position or its default is an interface change for every positional call site. RunConfig("gpt-4", 0.7) breaks the moment someone inserts a field before temperature.
This is why I default to keyword-only construction for anything that will outlive a single function:
@dataclass(kw_only=True)
class RunConfig:
model: str
temperature: float = 0.0
max_tokens: int = 512
kw_only=True removes the positional fragility entirely. You lose the ability to write RunConfig("gpt-4"), and you gain the ability to add fields without auditing call sites. For configuration and message records, that trade is almost always correct.
Knowledge check
Check your understanding
Answer this question before you continue.
Defaults Are Part of the Contract
Here is the mechanism behind the mutable-default rule, and it is worth understanding rather than memorizing.
Python stores default values as class attributes. When __init__ runs, it binds the instance attribute to whatever object the class attribute points at. If that object is mutable, every instance that does not override it shares the same object.
@dataclass
class Session:
tags: list = [] # TypeError at class definition time
Dataclasses raises TypeError for list, dict, and set defaults. That guard is partial by design — it catches the common cases, not the general one. A custom mutable object slips through:
class Buffer:
def __init__(self):
self.items = []
@dataclass
class Session:
buf: Buffer = Buffer() # no error, shared across instances
The correct mechanism is field(default_factory=...), which calls the factory per instance:
from dataclasses import dataclass, field
@dataclass
class Session:
tags: list[str] = field(default_factory=list)
buf: Buffer = field(default_factory=Buffer)
Now write the policy down. Every field should fall into exactly one of three categories:
| Policy | Declaration | Meaning |
|---|---|---|
| Required | name: T | Caller must supply it. No implicit value. |
| Safe immutable default | name: T = value | Shared value is fine because it cannot change. |
| Per-instance factory | name: T = field(default_factory=...) | Fresh object per instance. |
The failure mode to watch for is a nested dataclass or list field that looks per-instance but is shared. In a long-running service, that produces cross-request contamination: request A appends to a list, request B reads it, and the bug reproduces once every few hundred calls. If a field can be mutated after construction, it needs a factory or it needs to be frozen.
Knowledge check
Check your understanding
Answer this question before you continue.
Ownership: Frozen, Mutable, and Who May Write
Immutability is not a style preference. It is a statement about ownership.
frozen=True blocks attribute rebinding and makes instances hashable by default. It does not deep-freeze nested containers — a frozen dataclass holding a list still has a mutable list inside it.
@dataclass(frozen=True)
class Decision:
action: str
confidence: float
d = Decision("retry", 0.8)
d.action = "abort" # FrozenInstanceError
The useful split I use:
- Freeze objects that represent decisions, committed configuration, and messages already sent. Once a decision is made, nothing downstream should be able to rewrite it.
- Keep mutable objects that represent work in progress — accumulators, builders, in-flight state.
The hash interaction deserves an honest explanation, because it surprises people. A dataclass with eq=True (the default) and no frozen is unhashable, because defining __eq__ without __hash__ sets __hash__ to None. That is normal Python class behavior, not a dataclass quirk. The subtler case: a subclass that redefines __eq__ loses the inherited __hash__, even if the base class defined one explicitly. This is also standard Python semantics — the dataclass decorator is emulating what would happen if you had written __eq__ in the class body yourself. If you hit it, the fix is to declare the hash explicitly or set eq=False on the subclass.
Now the pattern that makes ownership concrete. In agent systems, I have seen state split by owner rather than by convenience:
@dataclass(frozen=True)
class ObservationState:
"""Owned by the observer. Written once per step."""
rendered: str
entities: tuple[str, ...]
@dataclass
class DynamicsState:
"""Owned by the simulator. Mutated across steps."""
hypotheses: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class LevelConstants:
"""Owned by the environment. Invariants for the whole run."""
grid_size: int
max_steps: int
@dataclass(frozen=True)
class Metadata:
"""Written by the harness. Read-only for the agent."""
run_id: str
seed: int
The point is not the specific fields. The point is that when a prediction fails, the failure can be routed to the component that owns the relevant state, because the ownership was declared at the type level. A repair becomes an addressing decision instead of an archaeology expedition.
But here is the boundary the type system does not enforce. A docstring saying "owned by the simulator" is a declaration, not a lock. DynamicsState is mutable, so any component holding a reference can call state.hypotheses.append(...) and violate the declared owner. The dataclass makes the intended owner legible; it does not prevent a rogue write. Enforcement comes from architecture — passing the object only to its owner, keeping it out of shared containers, or making it frozen when the owner is done with it.
# The type says the simulator owns this. Nothing stops the observer.
observer_state = DynamicsState()
observer_state.hypotheses.append("observer was here") # legal, wrong
If you need enforcement rather than declaration, you need one of three things: freeze the object, route it through an API that checks the caller, or keep it out of the hands of non-owners. The dataclass gives you the first option cheaply and makes the other two visible in review.
If two components can write the same field, the contract is underspecified. Split the object or freeze the field.
That is the decision rule. Apply it whenever you are tempted to add a "shared" mutable field.
Knowledge check
Check your understanding
Answer this question before you continue.
Validation Belongs at Construction
If invalid state can exist inside your system, you will eventually debug it. The cheapest place to prevent that is construction.
__post_init__ runs after the generated __init__ and is the natural home for local invariants:
@dataclass(frozen=True)
class RunConfig:
model: str
temperature: float = 0.0
max_tokens: int = 512
def __post_init__(self):
if not self.model:
raise ValueError("model must be non-empty")
if not 0.0 <= self.temperature <= 2.0:
raise ValueError(f"temperature out of range: {self.temperature}")
if self.max_tokens <= 0:
raise ValueError("max_tokens must be positive")
With frozen=True, __post_init__ cannot assign attributes normally. Use object.__setattr__:
@dataclass(frozen=True)
class Span:
start: int
end: int
length: int = field(init=False)
def __post_init__(self):
if self.end < self.start:
raise ValueError("end precedes start")
object.__setattr__(self, "length", self.end - self.start)
This is not a hack. It is the documented way to set derived fields on a frozen instance, and field(init=False) is what keeps callers from passing an inconsistent length themselves.
Keep validation local and cheap. A dataclass should enforce what must be true about this object. Cross-object rules, cross-service rules, and anything requiring a database lookup belong at a different boundary. And be clear about what dataclasses do not give you: no coercion, no rich error aggregation, no schema generation. If data arrives untrusted from outside your system, that is a different tool decision — and the next section shows where that decision actually lands.
Knowledge check
Check your understanding
Answer this question before you continue.
Serialization Is an Explicit Boundary
The most common wrong assumption about dataclasses is that they serialize themselves. They do not. asdict() and astuple() recurse into nested dataclasses, dicts, lists, and tuples, and produce plain Python containers — not JSON, and not converted types.
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
@dataclass
class Event:
name: str
at: datetime
e = Event("start", datetime.now(timezone.utc))
print(asdict(e))
{'name': 'start', 'at': datetime.datetime(2026, 1, 1, 12, 0, tzinfo=datetime.timezone.utc)}
That datetime object is not JSON-serializable. json.dumps(asdict(e)) raises TypeError. Enums, Decimal, and UUID behave the same way. You write the conversion.
The asymmetry is the part worth internalizing. Outbound conversion is one call. Inbound reconstruction is explicit per-field logic with its own error handling, because nothing walks your annotations to rebuild nested dataclasses from a dict.
Here is the paired pattern for a nested record, with the policy decisions made visible:
@dataclass(frozen=True)
class RetryPolicy:
max_attempts: int = 3
backoff: float = 1.5
@dataclass(frozen=True)
class RunConfig:
model: str
retry: RetryPolicy = field(default_factory=RetryPolicy)
def to_dict(self) -> dict:
return {
"model": self.model,
"retry": {
"max_attempts": self.retry.max_attempts,
"backoff": self.retry.backoff,
},
}
@classmethod
def from_dict(cls, data: dict) -> "RunConfig":
retry_data = data.get("retry", {})
return cls(
model=data["model"], # required: KeyError is the right failure
retry=RetryPolicy(
max_attempts=retry_data.get("max_attempts", 3),
backoff=retry_data.get("backoff", 1.5),
),
)
Three deliberate choices are encoded here. model uses data["model"] so a missing required field raises KeyError immediately — the failure is loud and located. retry uses .get() with defaults so an omitted nested block falls back to the declared policy. Extra keys in data are silently ignored; if you want to reject unknown fields, you add that check explicitly, because nothing does it for you.
That function is not boilerplate. It is the boundary, written down where a reviewer can see it. It is where you decide what a missing key means, what a malformed timestamp means, and whether the caller gets a KeyError, a ValueError, or a domain-specific exception.
My rule: convert to plain data at the edge of the system, keep dataclasses inside, and never let a raw dict travel past the boundary into core logic. If a dict reaches your business logic, the boundary was in the wrong place.
One cost note: asdict() deep-copies the structure. On large or deeply nested state, that is real work. When you are serializing hot-path state, a hand-written to_dict that skips fields you do not need is often worth the extra code.
Inheritance, Composition, and When Not to Use Dataclasses
Inheritance works, but it interacts badly with defaults. A base class with any default forces every derived field to have a default too, because Python requires non-default parameters to precede default ones. The result is accidental optionality: fields that should be required become silently omittable.
@dataclass
class Base:
id: int = 0
@dataclass
class User(Base):
name: str = "" # forced default, not a design choice
Prefer composition. A small dataclass nested inside another keeps each record stating one idea:
@dataclass(frozen=True)
class RetryPolicy:
max_attempts: int = 3
backoff: float = 1.5
@dataclass(frozen=True)
class RunConfig:
model: str
retry: RetryPolicy = field(default_factory=RetryPolicy)
Note the construction: retry=RetryPolicy(...) is explicit. Passing retry={"max_attempts": 5} stores the dict as-is. Nothing converts it for you.
Before reaching for a dataclass, match the tool to who owns the data and how much you trust it on arrival:
| Tool | Best for | Runtime checks | Serialization |
|---|---|---|---|
dict | Short-lived, local, genuinely flexible data | None | Already a dict |
| dataclass | Trusted, application-owned structures | Your __post_init__ invariants | asdict() out; explicit rebuild in |
| validation library | Untrusted or external data with contracts | Coercion plus aggregated errors | Schema tooling |
Two misuses I see regularly: wrapping a single field in a dataclass (a class that exists to hold one string is a naming exercise, not a contract), and using dataclasses as a substitute for a schema at an untrusted API boundary. If the data arrives from outside your trust boundary and you need coercion plus collected error messages, a validation library is the right tool. Dataclasses give you invariants you write, not validation you inherit.
A Small Drill: Model One Message Contract
Pick one real payload from your own code — a request record, a model message, a run configuration. Rewrite it as a dataclass and force three declarations:
- Required fields. No defaults. The caller must supply them.
- Factory defaults. Anything mutable gets
field(default_factory=...). - Frozen fields. Anything representing a committed decision gets
frozen=True.
Then add one __post_init__ invariant and one explicit from_dict/to_dict pair at the boundary.
Now break it deliberately. Add a field to a base class and observe which positional call sites fail. Pass a dict where the dataclass is expected and watch where the error surfaces. Hand a mutable state object to a component that does not own it and confirm that nothing stops the write — then decide whether that is acceptable or whether the field should be frozen. The point is not to enjoy the failure — it is to see exactly how far the contract reaches before it stops protecting you.
The success criterion is a review test: can a reviewer read the class and answer who owns each field, what the default policy is, and where the dict boundary sits, without reading the rest of the module? If yes, the contract is real. If no, you have written a container with better syntax.
Before you add the next dataclass, name the owner of each field, the default policy, and the exact line where the object becomes or stops being a plain dict. If any of the three is unclear, the contract is not ready — and the bug you would have shipped is still cheaper to prevent than to find.
Knowledge check
Final check
Finish the article by checking the ideas you just learned.
References
Research updated Sep 11, 2026


