Fields, Defaults, and Immutability
James defines a Note dataclass with six fields. Every time he creates a note, he has to pass all six values, even when most of them are the same. He types author="James" for the fifteenth time and sighs.
"Most notes have the same author and start as drafts," he says. "Can I set those as defaults?"
"You can," Emma says. "But there is one trap you need to see first. If you set a default the wrong way, two completely different notes will share the same data."
James raises an eyebrow. "How is that possible?"
"Let me show you the bug," Emma says. "Then you will never make it."
Default values let you skip arguments when creating an instance. Instead of always typing author="James", you define a default so that author is automatically "James" unless you specify otherwise. This lesson teaches defaults, shows a common bug with list defaults, and introduces a way to make instances read-only.
Simple Default Values
Fields with defaults work like function parameters with defaults. You assign a value directly:
from dataclasses import dataclass
@dataclass
class Note:
title: str
body: str
author: str = "Anonymous"
is_draft: bool = True
Now you can create a note without specifying author or is_draft:
note: Note = Note(title="Quick Thought", body="Remember to review tests.")
print(note)
Output:
Note(title='Quick Thought', body='Remember to review tests.', author='Anonymous', is_draft=True)
The defaults fill in automatically. You can still override them when needed:
note_by_james: Note = Note(
title="Meeting Notes",
body="Discussed timeline.",
author="James",
is_draft=False,
)
print(note_by_james.author) # James
print(note_by_james.is_draft) # False
Rule for field ordering: Fields without defaults must come before fields with defaults. This matches how function parameters work. Python needs to know which positional arguments are required before it can fill in optional ones.
# Correct: required fields first, defaults after
@dataclass
class Note:
title: str # required
body: str # required
author: str = "Anonymous" # optional (has default)
is_draft: bool = True # optional (has default)
# Wrong: Python raises an error
# @dataclass
# class BadNote:
# author: str = "Anonymous" # default
# title: str # no default AFTER a default = error
The Shared List Bug
Here is where things get dangerous. Suppose you want every note to start with an empty list of tags:
from dataclasses import dataclass
@dataclass
class BuggyNote:
title: str
body: str
tags: list[str] = [] # DO NOT DO THIS
This looks reasonable, but Python will reject it with an error:
ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory
Python's @dataclass decorator catches this mistake and stops you. But why would a default of [] be a problem?
Here is the bug that would happen if Python allowed it. (This bug does occur with regular classes that do not use @dataclass, so understanding it protects you in the future.)
The empty list [] would be created once, when the class is defined. Every instance that uses the default would share that same list object. Adding a tag to one note would add it to every note:
# If Python allowed it (it does not, but imagine):
note_a = BuggyNote(title="A", body="First")
note_b = BuggyNote(title="B", body="Second")
note_a.tags.append("python")
print(note_b.tags) # Would print: ["python"] (BUG!)
Note B would see the tag you added to Note A because both notes point to the same list in memory. This is one of the most common Python bugs, and @dataclass protects you from it by raising an error.
The Fix: field(default_factory=list)
The solution is field(default_factory=list). This tells Python: "every time you create a new instance, call list() to make a fresh empty list for that instance."
from dataclasses import dataclass, field
@dataclass
class Note:
title: str
body: str
author: str = "Anonymous"
is_draft: bool = True
tags: list[str] = field(default_factory=list)
Now each note gets its own independent list:
note_a: Note = Note(title="A", body="First")
note_b: Note = Note(title="B", body="Second")
note_a.tags.append("python")
print(note_a.tags) # ["python"]
print(note_b.tags) # [] (independent, no bug)
Think of default_factory as a factory that manufactures a fresh default every time an instance is created. The word "factory" means "something that produces new copies." list is the factory; each call to list() produces a new empty list.
Use field(default_factory=list) whenever a default value is a list, dict, or any other mutable (changeable) type. For simple immutable defaults like strings, numbers, and booleans, a plain = value is fine.
Quick rule: if the default has .append(), .update(), or other mutation methods, use default_factory.
Making Instances Read-Only with frozen=True
Sometimes you want to guarantee that a note cannot be changed after creation. In Lesson 2, you learned that @ passes your class through a function. You can give that function instructions using parentheses:
from dataclasses import dataclass, field
@dataclass(frozen=True)
class FrozenNote:
title: str
body: str
author: str = "Anonymous"
tags: list[str] = field(default_factory=list)
The frozen=True argument tells the dataclass() function to make all fields read-only. If you try to change a field after creation, Python raises an error:
note: FrozenNote = FrozenNote(title="Locked", body="Cannot change this.")
note.title = "New Title" # FrozenError!
Error:
dataclasses.FrozenInstanceError: cannot assign to field 'title'
Frozen dataclasses are useful when you want to be certain that data does not change after creation. If a function receives a FrozenNote, it cannot accidentally modify the original. This is a form of safety: the data model enforces rules that would otherwise depend on programmer discipline.
PRIMM-AI+ Practice: Defaults and Factories
Predict [AI-FREE]
Press Shift+Tab to enter Plan Mode before predicting.
Read this code without running it. Write down what each print statement outputs. Rate your confidence from 1 to 5.
from dataclasses import dataclass, field
@dataclass
class Task:
description: str
priority: int = 3
labels: list[str] = field(default_factory=list)
task_a: Task = Task(description="Write tests")
task_b: Task = Task(description="Review PR", priority=1)
task_a.labels.append("urgent")
print(task_a.priority)
print(task_b.priority)
print(task_a.labels)
print(task_b.labels)
Check your predictions
3
1
['urgent']
[]
task_a.priorityis3(the default).task_b.priorityis1(overridden at creation).task_a.labelsis['urgent']because we appended to it.task_b.labelsis[]becausedefault_factory=listcreated an independent list for each instance.
If you predicted that task_b.labels would also contain "urgent", revisit the Shared List Bug section above.
Run
Press Shift+Tab to exit Plan Mode.
Create a file called task_model.py with the code above. Run uv run python task_model.py and compare the output to your predictions.
Investigate
Change field(default_factory=list) back to = [] and run the file again. Does Python let you? What error message do you see? This is @dataclass protecting you from the shared list bug.
If you want to go deeper, run /investigate @task_model.py in Claude Code and ask why mutable defaults are dangerous in dataclasses.
Modify
Add a frozen=True argument to the @dataclass decorator. After creating task_a, try to change its priority:
task_a.priority = 1
Run the file. What error do you get? Remove the line and confirm the rest of the code works with frozen=True.
Make [Mastery Gate]
Without looking at any examples, define a dataclass called Config with these fields:
app_name: str(required)version: strwith default"1.0.0"debug: boolwith defaultFalseplugins: list[str]with a proper default factory
Create two instances. Append a plugin name to one instance's plugins list. Write assert statements proving:
- The second instance's
pluginslist is still empty - The
versiondefault is"1.0.0" - The
debugdefault isFalse
Run uv run python to verify all assertions pass.
Try With AI
If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.
Prompt 1: Explain the Bug
Why is this Python dataclass definition wrong?
@dataclass
class Note:
title: str
tags: list[str] = []
What bug would it cause, and what is the correct fix?
Read the AI's response. Does it explain the shared-list problem clearly? Does it recommend field(default_factory=list) as the fix? Compare its explanation to the factory metaphor from this lesson.
What you're learning: You are testing whether the AI gives the same advice you learned, reinforcing the correct pattern.
Prompt 2: Generate a Frozen Dataclass
Write a frozen Python dataclass called AppConfig with fields
for app_name (str), port (int with default 8080), and
allowed_origins (list of strings with proper default). Show
what happens when you try to modify a field after creation.
Review the AI's code. Verify that it uses @dataclass(frozen=True) and field(default_factory=list). Check that the error demonstration matches what you saw in this lesson.
What you're learning: You are validating AI-generated dataclass code against the patterns you now understand, building confidence to review real-world code.
Prompt 3: Decide What Should Be Frozen
I have a dataclass with these fields: user_id, username,
email, login_count, and preferences (a list of strings).
Which fields should be frozen (read-only after creation) and
which should stay mutable? Explain the tradeoff for each field
and show two versions: one fully frozen and one with only
certain fields protected.
Read the AI's reasoning before checking its code. Do you agree with its choices? For example, user_id should probably never change, but login_count needs to be updated. Consider which fields in your own domain data should be frozen. The key insight: frozen is not all-or-nothing in practice, even though @dataclass(frozen=True) applies to the whole class.
What you're learning: You are building judgment about when immutability helps and when it gets in the way, which is a design decision you will face in every project.
James creates two FrozenNote instances and tries to change one. The FrozenInstanceError stops him. "It is like sealing a shipping container at the dock. Once it is locked, nobody can swap the contents in transit."
"Good analogy," Emma says. "And default_factory is the machine that stamps a fresh packing list for every container. Without it, every container shares the same list, and adding an item to one container adds it to all of them."
James shudders. "I almost wrote tags: list[str] = [] before I saw the error."
"Everyone does," Emma says. "I still catch myself reaching for = [] on autopilot. The difference is that @dataclass blocks you, and in regular classes it does not. So this is one place where the decorator is genuinely saving you from a bug you would not notice until production."
"Now I want to see the full picture. We have the Note class, defaults, factories, frozen. How do I actually use this in SmartNotes?"
"You rewrite the functions, one at a time, and run the tests after each change. That is Lesson 5. The tests tell you whether the new code matches the old behavior."