CHAPTER 28 · INTERMEDIATE4 min read

Dataclasses and Models

Define records with useful generated behavior while keeping validation and mutability decisions explicit.

Python 3.12+Free to readModule 6 of 13
YOUR NEXT SMALL WINS

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.

task_model.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.

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.

independent_tags.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.

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.

frozen_value.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.

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.

LET IT CLICK

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.
TURN UNDERSTANDING INTO SOMETHING YOU CAN DO

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.

01

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.

your_turn_1.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.
Explore a worked solution
one_possible_solution.pyPython
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 == 750

The record uses integer minor units for exact arithmetic. Explicit checks enforce the runtime contract; annotations alone would not.

02

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.

your_turn_2.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.
Explore a worked solution
one_possible_solution.pyPython
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.

A SMALL QUESTION. A USEFUL NEXT STEP.

What stayed with you?

Check the ideas while they’re fresh. Your answers stay in this page and feedback appears as you go.

A LITTLE ACTIVE RECALL0 / 3 answered
01What does title: str on a dataclass field guarantee at runtime by itself?
02Why use field(default_factory=list)?
03A frozen dataclass contains a list field. Which statement is correct?
⚡CODING CHALLENGE
🔒Required to Mark Lesson Complete

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.

EXPECTED OUTPUT
[Process exited with code 0 - No output]
challenge.pyPython 3
Ctrl + ↵
Execution Console
Click "Run & Verify" (or press Ctrl + Enter) to test your code against the challenge.

Keep exploring.

The official references behind this chapter, when you want a second angle or the full details.

MODULE 06 · PRACTICAL SELF-CHECK

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 module

One chapter closer.

Reading is a start. Trying it yourself makes the connection.

Solve the hands-on coding challenge in the knowledge section to verify your code and unlock chapter completion.