Dataclasses and Models
Define records with useful generated behavior while keeping validation and mutability decisions explicit.
By the end, you’ll be able to…
- Create dataclasses with defaults and generated equality.
- Use default_factory for independent mutable fields.
- Validate models and distinguish frozen attributes from deep immutability.
01Declare the data a record carries
A dataclass is a normal class whose annotated fields drive generated methods such as __init__, __repr__, and value-based equality. It removes repetitive record plumbing while keeping ordinary methods available. Use one for a task, an import result, or a coordinate when the field names are part of the concept.
Annotations describe intended types but do not validate runtime values. Constructing Task(title=42) does not automatically reject the integer merely because title is annotated str. If external data needs enforcement, validate explicitly at the boundary or in __post_init__. Generated convenience is not a replacement for a data contract.
02Allocate mutable defaults per instance
A list used directly as a class-level default would be shared, which is almost never correct for a record’s own tags or notes. Use field(default_factory=list): the factory is called without arguments each time a default value is needed. Pass the callable list, not the result list().
Python 3.12 rejects unhashable field defaults such as a direct list, which catches many accidental mutable defaults. This check is a useful guard, not a proof that every accepted object is immutable. Use a factory for any default that should be a fresh instance, including custom mutable containers.
03Choose mutation and equality deliberately
By default, dataclass equality compares the declared fields of two instances of the same class. That is useful for value-like records and tests. It may be wrong for entities whose identity is a stable identifier regardless of other changing fields. Decide whether comparing every field matches your domain before relying on generated equality.
frozen=True rejects ordinary assignment to fields and can suit value objects. It does not recursively freeze contained objects: a list field can still be appended to. Prefer immutable field values such as tuples when deeper stability is needed. replace creates a new dataclass instance with selected changes and runs initialization again.
04Keep representation boundaries explicit
asdict converts dataclass fields into a dictionary, recursively handling nested dataclasses and containers. It does not make every value JSON serializable: datetime, Path, and other custom objects still need an agreed representation. Explicit conversion functions can be clearer when a saved format must remain stable across code changes.
Put required fields before fields with defaults, unless you deliberately use keyword-only fields. Avoid adding a new mandatory positional field casually to a widely used model. For persistent records, consider versioning the saved representation separately from the class so refactoring an attribute does not silently invalidate existing files. Tests should cover decoding older supported records as well as constructing new ones.
The ideas worth keeping.
- Dataclasses generate record behavior, not runtime validation.
- Use factories for fresh mutable defaults.
- Choose equality, persistence representation, and mutability as separate design decisions.
Make it yours.
Type and test your code right here in the interactive workspace. Run your solution, check the output, and compare with the worked guide when ready.
Validate a line item
Create a LineItem dataclass with name, unit_cents, and quantity defaulting to 1. Reject a blank name and negative amounts; expose total_cents as a property.
Explore a worked solution
from dataclasses import dataclass@dataclassclass LineItem: name: str unit_cents: int quantity: int = 1 def __post_init__(self): if not isinstance(self.name, str) or not self.name.strip(): raise ValueError("name must be nonempty") if type(self.unit_cents) is not int or self.unit_cents < 0: raise ValueError("unit_cents must be a nonnegative integer") if type(self.quantity) is not int or self.quantity < 0: raise ValueError("quantity must be a nonnegative integer") @property def total_cents(self): return self.unit_cents * self.quantityassert LineItem("Notebook", 250, 3).total_cents == 750The record uses integer minor units for exact arithmetic. Explicit checks enforce the runtime contract; annotations alone would not.
Model an immutable label set
Define a frozen Bookmark record with url and labels as a tuple. Create a second value with one extra label without mutating the first.
Explore a worked solution
from dataclasses import dataclass, replace@dataclass(frozen=True)class Bookmark: url: str labels: tuple[str, ...] = ()original = Bookmark("https://docs.python.org/3.12/", ("python",))updated = replace(original, labels=original.labels + ("reference",))assert original.labels == ("python",)assert updated.labels == ("python", "reference")Frozen fields and immutable tuple contents make this value stable for normal use. replace expresses a change by constructing another instance.
What stayed with you?
Check the ideas while they’re fresh. Your answers stay in this page and feedback appears as you go.
Validate a line item
Create a LineItem dataclass with name, unit_cents, and quantity defaulting to 1. Reject a blank name and negative amounts; expose total_cents as a property.
[Process exited with code 0 - No output]
Keep exploring.
The official references behind this chapter, when you want a second angle or the full details.
Pause. Put the pieces together.
You’ve reached the end of Objects and Data Models. Before moving on, use this task to check what you can do independently.
Create two independent instances and verify that a rejected update leaves the original state unchanged.
Try it in your own environment and write down the result. This is a self-check; reading completion does not evaluate your code.
Review this moduleOne chapter closer.
Reading is a start. Trying it yourself makes the connection.