LESSON 32 · DEEPER PYTHON28 min

Store Structured Data with dataclasses

Define a compact data class and give each instance its own mutable list field.

Before you start: Classes and Objects · Functions · Lists

Video lesson: Store Structured Data with dataclasses

The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube

Read the video transcript

Select a timestamp to watch that moment on YouTube.

  1. 00:00

    A small study planner needs records with a title, a done flag, and a list of tags. A normal Python class can hold that data, but writing an initializer, a readable representation, and equality checks by hand gets repetitive. A dataclass can generate those methods from declared fields. The catch is the list of tags: each task needs its own list. If we try to use one empty list as the default field value, modern Python refuses to create the dataclass. We will run that failure, inspect why it is useful, and fix it with a factory that makes a fresh list for each instance. The companion lesson has runnable examples and a checked task.

  2. 00:42

    Before the first run, state the behavior you need: changing one task's tags must not change another task. That requirement is more important than the syntax used to achieve it. A dataclass is still a Python class, with instances that can be changed unless you choose other options. Its type annotations describe fields for tooling and for dataclass method generation; they do not automatically validate every value at runtime. The class definition failed before we created even one task. The decorator saw a mutable list used as a field default and raised ValueError. That guard protects us from a familiar shared-state bug.

  3. 01:24

    In Python, a default expression at class definition time is not rerun merely because we create another instance. Reusing one list could make a tag added to the Loops task appear in the Files task. The same general hazard occurs with a mutable default argument in a function. We do not want to silence this error or replace the list with a disguised shared object. We want a new empty list each time a StudyTask instance is built without an explicit tags value. `field(default_factory=list)` expresses exactly that rule. The factory is the list class, passed without parentheses. Calling `list()` at the class definition would make one list immediately, bringing back the sharing risk.

  4. 02:10

    Keep the timing clear: `default_factory=list` tells the generated initializer to call list later, separately for each new instance. We will verify that behavior with two objects rather than trusting the syntax alone. The decorator generated an initializer so `SimpleTask('Loops')` works without a handwritten `__init__`. It also generated a representation that names the fields, and equality that compares two SimpleTask values field by field. This is useful for small data records where the fields are the main point of the class. Compare that with an ordinary class that does not implement equality: two separate instances are generally compared by identity.

  5. 02:53

    A dataclass does not make the fields immutable by default. We can still set done to True after creation, or append a tag to a list field. The missing piece is a separate list for every object. Before adding it, distinguish an annotation from a value. `tags: list[str]` tells readers and tools the intended type. `= []` would supply a default value, and that particular value is mutable. Python does not automatically enforce the annotation, so passing the wrong type may still create an object and fail later when code uses it. Dataclasses remove routine method-writing; they do not replace thoughtful validation and tests.

  6. 03:35

    The first task now has one tag while the second remains empty. Each default factory call made its own list. The generated initializer still accepts an explicit tags list when we want to provide one, but using the default is safe across new instances. The done field is different: False is immutable, so one task cannot modify the same Boolean object in place and thereby change another task. A dataclass field with a default must follow fields without defaults in the class definition. That is why title appears before done and tags. Our website task asks you to complete this record and demonstrate that separate tasks do not share tag state.

  7. 04:18

    The exercise checker also looks at equality and the default done flag. Try adding a tag to Files and confirming that Loops still has only its original tag. If you later need more than a list, `default_factory` can also create a new dict, set, or a custom container for each instance. The central rule remains the same: one mutable container per record, unless sharing is a deliberate design choice. The two objects initially compare equal by field values, yet they are not the same object and their tag lists are separate objects. Changing one tag list breaks their value equality, as we would expect. `asdict` converts the dataclass fields into a normal dictionary for a simple report or a further transformation.

  8. 05:06

    It is not a complete storage format by itself; writing JSON or saving to disk raises separate questions about supported types and validation. On PythonLessonLab, start with the smallest StudyTask, add the tags field with a factory, and run the task checks. Then finish the quiz and create two tasks of your own. Try marking one done and predict whether it changes the other. If a mutable default error appears in another dataclass, treat it as a clue about object lifetime rather than a nuisance. Ask whether each new instance needs its own value. Most task records do, and a factory makes that intent explicit and testable.

Understand the concept

A small class often exists mainly to store related values. Decorating it with @dataclass lets Python generate an initializer, a readable representation, and value-based equality from annotated fields. For example, a point with x and y fields can be created as Point(2, 3) without writing __init__ by hand. A data class can still define ordinary methods when behavior belongs with the data.

A field with a simple default, such as done: bool = False, is optional when constructing an instance. Put required fields before fields with defaults. An annotation describes the intended type, but Python does not check it automatically at runtime. Test or validate inputs separately when incorrect values would cause harm.

A mutable list needs a new object for each instance. Write tags: list[str] = field(default_factory=list), not tags: list[str] = []. The factory runs when a new instance needs the default. If one task receives a tag, another task's empty tag list must stay empty. This avoids the shared-state bug that appears with mutable defaults in ordinary functions and classes.

The exercise extends StudyTask with an independent list of tags while keeping its title and done fields. Verify that two objects with equal field values compare equal, that changing one object's tags changes that equality, and that a newly created task has its own empty list. Use asdict only when a plain dictionary copy is needed for output or serialization.

  • @dataclass generates __init__, repr, and equality
  • Annotated fields determine stored data
  • default_factory creates an independent list per instance
  • Type annotations do not enforce runtime types

See it step by step

Read the code, predict the output, then compare it with the result.

01. Get a readable representation and equality

PYTHON
from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int

print(Point(2, 3))
print(Point(2, 3) == Point(2, 3))
EXPECTED OUTPUT
Point(x=2, y=3)
True

@dataclass generated the initializer, repr, and value-based equality from x and y.

02. Give each instance its own list

PYTHON
from dataclasses import dataclass, field

@dataclass
class StudyTask:
    title: str
    tags: list[str] = field(default_factory=list)

first = StudyTask("Loops")
second = StudyTask("Files")
first.tags.append("basics")
print(first.tags, second.tags)
EXPECTED OUTPUT
['basics'] []

default_factory=list constructs a fresh list for each instance; changing first does not change second.

03. Convert a data class to a dictionary

PYTHON
from dataclasses import asdict, dataclass

@dataclass
class StudyTask:
    title: str
    done: bool = False

print(asdict(StudyTask("Review loops", True)))
EXPECTED OUTPUT
{'title': 'Review loops', 'done': True}

asdict returns a regular dictionary with the annotated fields and their current values.

A closer look

Follow the reasoning, inspect each result, then try the suggested changes in the console below.

01 / 03

Let a dataclass supply common methods

A dataclass is useful for a small record whose fields have clear names. The @dataclass decorator generates an initializer, a readable representation, and value-based equality from the annotated fields. You can create Point(2, 3) without writing a repetitive __init__ method. Printing it shows the field names, while two separately created points with the same field values compare equal.

The annotation x: int communicates an intended type, but dataclasses do not enforce it at runtime by themselves. If input comes from an external source, validate or convert it before constructing the record. frozen=True prevents ordinary assignment to fields after construction, which suits a point intended to be a value. It does not make arbitrary mutable objects stored inside a frozen dataclass deeply immutable.

PYTHON
from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: int
    y: int

point = Point(2, 3)
print(point)
print(point == Point(2, 3))
print(point.x + point.y)
EXPECTED OUTPUT
Point(x=2, y=3)
True
5
Follow the reasoning
  1. The decorator supplies initialization and a readable repr.
  2. Equality compares the field values of two Point objects.
  3. The annotations describe intended values but do not automatically validate input types.
02 / 03

Give each instance its own mutable list

A list changes when items are appended, so it must not be silently shared between otherwise independent records. A dataclass field with default_factory=list calls list separately for every new instance. The two study tasks below start without tags; adding a tag to one leaves the other alone. Modern dataclasses reject a directly specified list default for this reason, and default_factory expresses the intended ownership clearly.

Fields without defaults must appear before fields with defaults in the class definition. A factory is evaluated when an instance is created, not when the class is defined. This pattern also works for dictionaries and sets when those containers should belong to one record each. If you intend records to share one central collection, put that collection in an explicit separate object instead of relying on an accidental class-level default.

PYTHON
from dataclasses import dataclass, field

@dataclass
class StudyTask:
    title: str
    done: bool = False
    tags: list[str] = field(default_factory=list)

first = StudyTask("Learn loops")
second = StudyTask("Practice functions")
first.tags.append("beginner")
print(first.tags)
print(second.tags)
print(first.done)
EXPECTED OUTPUT
['beginner']
[]
False
Follow the reasoning
  1. default_factory=list creates a new tags list for each StudyTask.
  2. Appending to first changes only first.tags.
  3. The second task retains its own empty list, and done uses its False default.
03 / 03

Convert nested records to dictionaries

dataclasses.asdict turns a dataclass instance into a dictionary keyed by field names. It recursively converts nested dataclasses, so a Cart containing Item values becomes a dictionary with a list of item dictionaries. This can be a useful boundary before JSON serialization, though asdict itself does not write JSON or a file. The conversion produces a separate structure; changing the converted dictionary does not update the original cart.

Use the dataclass objects while business logic benefits from named attributes such as item.price. Convert when an interface expects plain dictionaries. asdict recursively copies the nested values, which can cost time and memory for very large structures; choose an explicit mapping if you need a smaller payload or a different public format. In the example, changing the copied price to 99 leaves the original item price at 2.

PYTHON
from dataclasses import dataclass, field, asdict

@dataclass
class Item:
    name: str
    price: int

@dataclass
class Cart:
    items: list[Item] = field(default_factory=list)

cart = Cart([Item("Pen", 2), Item("Book", 5)])
data = asdict(cart)
print(data)
print(sum(item.price for item in cart.items))
data["items"][0]["price"] = 99
print(cart.items[0].price)
EXPECTED OUTPUT
{'items': [{'name': 'Pen', 'price': 2}, {'name': 'Book', 'price': 5}]}
7
2
Follow the reasoning
  1. asdict recursively converts the two Item records inside Cart.
  2. The original object still supports direct named-attribute arithmetic.
  3. Editing the converted dictionary does not mutate the cart's nested Item.

Try it in Python

Edit the example and run it. Python starts in your browser the first time you click Run.

Python console

Ready to run
Need input()? Add one value per line
Ctrl / ⌘ + Enter to run
OUTPUT
Your output appears here.
Your turnAdd a tags field to the StudyTask data class. It must be a list of strings and default to a separate empty list for every instance. Keep title required and done defaulting to False. The prepared call should print Loops False. Two identical tasks should compare equal, while adding a tag to one should not change another task's tags.
Need a hint?

Add tags: list[str] = field(default_factory=list) after done. The factory creates a fresh list for each new StudyTask.

Complete the task and select Check task to verify your code.

    Quick quiz

    Three questions. You can change your answers and try again.

    01.Which methods does @dataclass normally generate from annotated fields?

    02.Why use field(default_factory=list) for a tags list?

    03.Do type annotations on a data class automatically reject a wrong runtime type?

    Typical mistakes

    Everyone meets these errors. See what causes them and how to fix them.

    Using a mutable list as a direct default

    COMMON MISTAKE
    @dataclass
    class StudyTask:
        title: str
        tags: list[str] = []
    FIX
    @dataclass
    class StudyTask:
        title: str
        tags: list[str] = field(default_factory=list)

    What happens: Modern Python raises ValueError at class creation because a mutable list is an unsafe default.
    A factory supplies an independent list each time an instance is created.

    Putting a required field after a default

    COMMON MISTAKE
    @dataclass
    class StudyTask:
        done: bool = False
        title: str
    FIX
    @dataclass
    class StudyTask:
        title: str
        done: bool = False

    What happens: TypeError: non-default argument 'title' follows default argument.
    Keep required fields before optional fields with defaults.

    Assuming an annotation validates data

    COMMON MISTAKE
    task = StudyTask(123)
    # Assume title must be a str at runtime.
    FIX
    if not isinstance(title, str):
        raise TypeError('title must be text')
    task = StudyTask(title)

    What happens: The instance can still be created with an integer title.
    Use explicit validation when a runtime guarantee matters; an annotation alone is descriptive.

    Browse this lesson’s problem fixes

    Sources and further reading