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]
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
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.
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.
Key Takeaways
-
Fields with defaults let you skip arguments at creation time. Put required fields first, fields with defaults after. The pattern matches function parameter ordering.
-
Never use a mutable default like
= []directly. Python's@dataclassblocks this with an error. Usefield(default_factory=list)to create a fresh list for each instance. -
default_factoryis a factory that manufactures fresh defaults. Each time an instance is created, the factory runs and produces a new, independent value. This prevents the shared-list bug. -
@dataclass(frozen=True)makes instances read-only. Any attempt to change a field after creation raisesFrozenInstanceError. This enforces data integrity at the language level. -
Decorator arguments extend decorator behavior. In Lesson 2 you learned that
@passes your class through a function.@dataclass(frozen=True)passes instructions to that function, telling it to add immutability.
Looking Ahead
You now know how to define dataclasses with defaults, safe list fields, and immutability. In Lesson 5, you will put it all together by transforming the SmartNotes project from dictionaries to dataclasses and writing tests that prove the new structure works.