Python Dataclasses: Clear Records Still Need Real Validation
Build explicit Python record contracts with dataclasses, post-init validation, default factories, shallow immutability, and careful serialization.
Reviewed October 6, 2026. Examples were executed with Python 3.12.14. Records and policies are original teaching fixtures, not a prescribed external data standard.
A project begins with dictionaries containing a session name and duration. Later, three functions spell the duration field differently, callers pass a string where an integer was expected, and two records unexpectedly share one mutable collection. A dataclass can make the record's structure easier to read, but adding a decorator does not automatically solve those contract problems.
A useful dataclass combines explicit field names with explicit construction rules. Its generated initializer, representation, and equality methods save repetitive code. Type annotations describe intended types to readers and type-checking tools. Runtime validation remains a separate responsibility unless your application deliberately supplies it.
Practical rule: use dataclasses for named Python records, validate the invariant at a clear boundary, and choose mutability deliberately. Keep conversion from external documents separate from object construction when the external format has its own spelling, version, or error-reporting requirements.
What the decorator generates, and what it does not
The dataclasses documentation describes fields identified from class annotations and the methods that can be generated. It does not promise general runtime enforcement of annotated types. An annotated minutes field can still receive text if no validation rejects it.
from dataclasses import dataclass
@dataclass
class UncheckedSession:
topic: str
minutes: int
record = UncheckedSession("functions", "thirty")
assert record.minutes == "thirty"
print(record)
This example is intentionally unchecked. The fact that construction succeeds is evidence of the distinction, not a recommended record for an analysis pipeline. The typing documentation likewise distinguishes type annotations from automatic runtime enforcement.
Dataclass equality normally compares field values between instances of the same class, under its generated equality method. It is not a semantic rule saying every object with similarly named fields is interchangeable. Ordering, hashing, and comparison exclusions are additional design choices; do not enable them simply because the decorator offers options.
Establish the invariant during construction
Our session contract requires a trimmed, nonempty topic and actual integer minutes from zero through 240. Boolean values are rejected even though bool is a subclass of int. We normalize surrounding topic whitespace but do not silently round a float or parse numeric text inside the record constructor.
from dataclasses import dataclass, FrozenInstanceError, replace
@dataclass(frozen=True)
class Session:
topic: str
minutes: int
def __post_init__(self):
if not isinstance(self.topic, str) or not self.topic.strip():
raise ValueError("topic must be nonempty text")
if type(self.minutes) is not int or not 0 <= self.minutes <= 240:
raise ValueError("minutes must be an integer from 0 to 240")
object.__setattr__(self, "topic", self.topic.strip())
session = Session(" functions ", 30)
assert session == Session("functions", 30)
assert Session("review", 0).minutes == 0
updated = replace(session, minutes=45)
assert updated.minutes == 45 and session.minutes == 30
for bad in [True, "30", 30.0, -1, 241]:
try:
Session("functions", bad)
except ValueError:
pass
else:
raise AssertionError("Invalid duration accepted")
try:
session.minutes = 60
except FrozenInstanceError:
pass
else:
raise AssertionError("Frozen assignment should fail")
print(session, updated)
The post-init method runs after the generated initializer. Frozen instances prevent ordinary attribute reassignment, so the example uses object.setattr during initialization to establish its normalized value. That is a deliberate implementation technique, not proof that frozen is an unbreakable security boundary.
The replace operation constructs a replacement record and invokes initialization behavior. It is useful for expressing a changed value without mutating the original. Test the replacement boundary too: changing minutes to a rejected value should not create an invalid new instance.
| Input | Result | Contract decision |
|---|---|---|
| Topic with surrounding spaces | Trimmed topic | Normalize this field deliberately |
| Minutes 0 | Accepted | Zero is a valid measurement |
| Minutes True | Rejected | A flag is not a duration |
| Minutes text 30 | Rejected | Conversion belongs at an external parsing boundary |
| Minutes 241 | Rejected | The application's upper limit is 240 |
Default factories prevent accidental shared state
A mutable collection should usually be created separately for each record. field(default_factory=list) calls list when a value is needed, producing a fresh outer collection. Supplying a preexisting list explicitly is a different ownership decision: the constructor receives that actual object unless your code copies it.
from dataclasses import dataclass, field
@dataclass
class StudyPlan:
name: str
topics: list[str] = field(default_factory=list)
first = StudyPlan("A")
second = StudyPlan("B")
first.topics.append("functions")
assert first.topics == ["functions"]
assert second.topics == []
shared = ["files"]
third = StudyPlan("C", shared)
shared.append("JSON")
assert third.topics == ["files", "JSON"]
print("A default factory creates defaults; it does not copy supplied values")
This distinction matters when data crosses layers. A caller might retain the supplied list and change it after validation. If your record's invariant depends on that collection staying unchanged, choose an immutable representation or copy and validate its contents under a clearly stated policy.
A default factory also does not validate its eventual contents. A list annotated list[str] can still receive an integer through append at runtime. If that violates the domain, centralize updates behind validated methods or use a representation whose accepted values are established during construction.
Frozen is shallow, and slots is not a performance result
A frozen record containing a list still contains a mutable list. Preventing replacement of the list attribute does not prevent changes to the list itself. For deeply stable values, immutable nested structures are often easier to reason about, but a tuple can itself contain mutable members. Follow the object graph rather than relying on one decorator option.
Slots can restrict ordinary per-instance attribute storage and prevent many accidental new attributes. Their exact memory benefit depends on the class hierarchy, interpreter, workload, and measurement method. This article does not claim a universal percentage reduction or speed gain. Validate behavior first, then measure a representative population if memory is the reason to use slots.
Hashability is another separate decision. A frozen record with only suitable immutable fields may support hashing under generated rules, while a frozen record containing an unhashable list can fail when used as a dictionary key. Freezing and hashing should match the actual fields and the intended identity policy.
| Choice | Helps express | Does not establish |
|---|---|---|
| Type annotations | Intended field types | Automatic runtime validation |
| Post-init checks | Construction invariants | Protection against every later mutation |
| Default factory | Fresh default containers | A copy of explicitly supplied objects |
| Frozen | Ordinary assignment restrictions | Recursive immutability or security |
| Slots | Controlled attribute storage | A measured application performance gain |
Keep serialization and external compatibility explicit
A dataclass instance is not automatically a JSON document. asdict recursively converts dataclass-related structure and deep-copies other objects; it is not simply a zero-cost view. Values such as dates, Decimal objects, and paths still need an explicit JSON representation.
For a stable external format, write a small serializer that names the fields and version you promise. Parse external text, validate its document shape, convert appropriate field values, and then construct the record. This produces more useful error messages than forwarding arbitrary dictionary keys into an initializer and hoping its exceptions describe the source problem.
If the external schema changes, decide whether unknown fields are rejected, ignored, or retained. That policy belongs to the document boundary. A dataclass can support the internal model without becoming the only definition of a durable storage contract.
Verify the record's behavior
Test empty and whitespace-only topics, both numeric boundaries, Boolean minutes, type mismatches, equality after normalization, and replacement with invalid values. For collections, test two independently constructed defaults and one explicitly shared input. For persistence, inspect the serialized format and perform a round trip with known values.
A clear record makes invalid states easier to identify. It does not remove the need to decide what counts as invalid. The best dataclass is the one whose field names, normalization choices, mutation model, and boundary tests tell the same story.
Sources and reproducibility
Primary references: dataclasses, typing, and Python classes and shared state. All three examples are independently runnable. Their validation policies are deliberately scoped teaching choices.