Session 28 of the Python Fundamentals series, covered in depth. The @dataclass decorator writes the boilerplate that a plain class needs by hand: __init__, a readable __repr__, and a value-based __eq__. The session covers type-annotated fields, default values, default_factory for mutable defaults such as lists, frozen=True for read-only records, and field(), along with when a dataclass is the right tool - plain data records - and when it is not. The traps are that a bare mutable default raises ValueError: mutable default ... is not allowed, that a field with a default placed before a required one raises a TypeError, and that assigning to a frozen instance raises a FrozenInstanceError. Every snippet and error message was executed and copied verbatim from CPython 3.12.
Subject: Python Fundamentals · 95 slides · code lesson
Open the interactive version of this deck · Homework for this lesson
Title
Python Fundamentals - Session 28
One decorator writes the boilerplate every data record needs
Objectives
You already write classes. Many of them just bundle a few values together. A dataclass removes the repetitive parts. By the end you can:
@dataclass with type-annotated fields.__init__, __repr__, __eq__.default_factory for lists.frozen=True.ValueError and the field-order TypeError.Warm-up
Discussion prompt
Before we open Session 28 - Dataclasses: without looking back, what was the main idea of Session 27 - classmethod, staticmethod & property, and what could you do by the end of it that you could not do before?
Hint: One sentence for the idea, one for the skill. If the second one is blank, that is the part to revisit.
Answer:
Three decorators that reshape how a class exposes behavior: @staticmethod (a namespaced helper with no self), @classmethod (cls-based alternate constructors like from_string), and @property (a computed attribute with an optional validating setter). Covers read-only properties, the no-setter AttributeError, and raw-attribute vs property-setter validation.
Section
Part 1
Concept
A point with an x and y. A player with a name and score. A config with a host and port. These are just a few values traveling together.
data record — An object whose whole job is to hold a fixed set of named values. It has data, and little or no behavior.
Counterexample
Discussion prompt
A point with an x and y. A player with a name and score. A config with a host and port. These are just a few values traveling together.
That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.
Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.
Concept
To make a plain class usable, you hand-write __init__ to store the fields, __repr__ so printing is readable, and __eq__ so equal values compare equal.
That is a lot of typing for a bag of two values - and it is the same shape every time.
Analogy
Discussion prompt
Explain A plain class makes you write three methods by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.
Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.
Answer:
To make a plain class usable, you hand-write __init__ to store the fields, __repr__ so printing is readable, and __eq__ so equal values compare equal.
Fill the middle
Fill in the blanks
From A hand-written class prints unhelpfully — one line has had its right-hand side removed. Put it back.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(1, 2)
print(p.x, p.y)
print(p)
Why: p is what everything below it consumes, so the wrong expression here fails later and somewhere else. __init__ stored x and y, so p.x and p.y print 1 2.
Worked example
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(1, 2)
print(p.x, p.y)
print(p)The fields work fine
Why: __init__ stored x and y, so p.x and p.y print 1 2.
But printing the object is useless
Why: With no __repr__, Python shows the class name and a memory address - not the values.
| line | output |
|---|---|
| print(p.x, p.y) | 1 2 |
| print(p) | <__main__.Point object at 0x...> |
Comparison
Comparison matrix
From A hand-written class prints unhelpfully: refill the output column from what you know. The rest of the table is as it appeared.
| line | output |
|---|---|
| print(p.x, p.y) | 1 2 |
| print(p) | <__main__.Point object at 0x...> |
Faded example
Fill in the blanks
And two equal points are not equal, with the scaffolding fading: two lines are gone now — fill both.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
a = Point(1, 2)
b = Point(1, 2)
print(a == b)
Why: Reproducing these unaided, rather than reading them, is what tells you the method has transferred. Both were built as Point(1, 2), so field-by-field they match.
Worked example
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
a = Point(1, 2)
b = Point(1, 2)
print(a == b)a and b hold the same values
Why: Both were built as Point(1, 2), so field-by-field they match.
Yet == is False
Why: With no __eq__, Python falls back to identity - are they the SAME object? They are not, so it prints False.
| expression | value |
|---|---|
| a == b | False |
Error analysis
Annotate
Walk the callouts on And two equal points are not equal. Each one is a place this is easy to get subtly wrong.
Intuition
Every data record needs the same trio: store the fields, print them, compare them. The names change; the shape never does.
When you write the exact same structure again and again, that is a job for the computer. A dataclass generates that trio for you.
Explain it
Discussion prompt
Explain You keep rewriting the same three methods to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.
Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.
Answer:
Every data record needs the same trio: store the fields, print them, compare them. The names change; the shape never does.
Section
Part 2
Concept
Import dataclass from dataclasses and put @dataclass on the line above class. It generates __init__, __repr__, and __eq__ from your fields.
@dataclass — A decorator that inspects a class's annotated fields and auto-writes __init__, __repr__, and __eq__ so you do not have to.
Intuition
You do not get magic behavior - you get real, ordinary methods, written for you at class-creation time from the fields you listed.
Anything the generated method does, you could have typed by hand. The decorator just saves you the typing and the typos.
Concept
Inside the class you list each field as name: type - like x: int. No self, no __init__. The annotation is how the decorator finds the field.
The type is a hint for readers and tools; Python does not enforce it at runtime. x: int will still accept a string.
Worked example
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
p = Point(1, 2)
print(p)
print(p.x, p.y)No __init__, just two annotated fields
Why: @dataclass reads x: int and y: int and writes __init__(self, x, y) for you.
Printing is now readable
Why: The generated __repr__ shows the class name and every field. Verified by execution.
| line | output |
|---|---|
| print(p) | Point(x=1, y=2) |
| print(p.x, p.y) | 1 2 |
Trade off
Comparison matrix
From The same Point as a dataclass: every row here is a choice with a cost. Fill the output column, then say which row you would actually pick and what you give up for it.
| line | output |
|---|---|
| print(p) | Point(x=1, y=2) |
| print(p.x, p.y) | 1 2 |
Worked example
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
a = Point(1, 2)
b = Point(1, 2)
c = Point(3, 4)
print(a == b)
print(a == c)The generated __eq__ compares fields
Why: a and b have the same x and y, so they are equal even though they are different objects.
Different values compare unequal
Why: c has x=3, y=4, so a == c is False. Verified by execution.
| expression | value |
|---|---|
| a == b | True |
| a == c | False |
Blank canvas
Draw it
Draw what Equality now compares values just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.
Section
Part 3
Concept
The generated __repr__ reads ClassName(field=value, ...). It shows up whenever you print the object or view it in a list - which makes debugging far easier.
Worked example
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
pts = [Point(0, 0), Point(1, 2)]
print(pts)Printing a list calls each item's repr
Why: Each Point renders as Point(x=..., y=...), so the whole list is readable.
Read the output
Why: Verified by execution. Compare this to a list of <object at 0x...> lines.
| expression | output |
|---|---|
| print(pts) | [Point(x=0, y=0), Point(x=1, y=2)] |
Concept
The generated __eq__ says two instances are equal when they are the same class and every field matches. This is what you almost always want for a record.
Because in and .index() rely on ==, they now work by value too.
Sorting
Sort into buckets
These are the pieces of Session 28 - Dataclasses, out of order. Put each one back under the part of the lesson it belongs to.
Worked example
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
pts = [Point(0, 0), Point(1, 2)]
print(Point(1, 2) in pts)A fresh Point(1, 2) is not in the list by identity
Why: It is a brand-new object - no element is the same object.
But in uses ==, which compares values
Why: Point(1, 2) equals the second element by value, so in is True. Verified by execution.
| expression | value |
|---|---|
| Point(1, 2) in pts | True |
Worked example
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
@dataclass
class Pixel:
x: int
y: int
print(Point(1, 2) == Pixel(1, 2))Same fields, same values, different class
Why: Point and Pixel both have x and y set to 1 and 2.
The generated __eq__ checks the class first
Why: It returns False because the two objects are not the same type. Verified by execution.
| expression | value |
|---|---|
| Point(1, 2) == Pixel(1, 2) | False |
Section
Part 4
Concept
Write score: int = 0 and the caller may omit score when constructing. Leave it out and the default is used; supply it and it overrides.
Fill the middle
Fill in the blanks
From A player with a default score — one line has had its right-hand side removed. Put it back.
from dataclasses import dataclass
@dataclass
class Player:
name: str
score: int = 0
a = Player("Ana")
b = Player("Ben", 50)
print(a)
print(b)
Why: b is what everything below it consumes, so the wrong expression here fails later and somewhere else. Only name was given; score falls back to the default 0.
Worked example
from dataclasses import dataclass
@dataclass
class Player:
name: str
score: int = 0
a = Player("Ana")
b = Player("Ben", 50)
print(a)
print(b)Ana omits score, so it defaults to 0
Why: Only name was given; score falls back to the default 0.
Ben supplies score, overriding the default
Why: Verified by execution. The default only fills in when the argument is missing.
| call | score used | repr |
|---|---|---|
| Player("Ana") | 0 (default) | Player(name='Ana', score=0) |
| Player("Ben", 50) | 50 | Player(name='Ben', score=50) |
Concept
The generated __init__ is an ordinary function, so its parameters obey the ordinary rule: a parameter with a default cannot come before one without a default.
Put every required field first, then the fields that have defaults.
Anomaly
Predict first
A student writes this, and it looks reasonable:
The field with a default is listed first.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: The generated __init__ would be (price=0.0, name) - a required parameter after a defaulted one, which Python forbids.
List required fields first, defaults last.
Why: The generated __init__ would be (price=0.0, name) - a required parameter after a defaulted one, which Python forbids. It raises at class-creation time.
Trap
The field with a default is listed first.
from dataclasses import dataclass
@dataclass
class Item:
price: float = 0.0
name: strname has no default but follows price, which does
Why: The generated __init__ would be (price=0.0, name) - a required parameter after a defaulted one, which Python forbids. It raises at class-creation time.
| what happens | result |
|---|---|
| @dataclass builds __init__ | TypeError: non-default argument 'name' follows default argument |
List required fields first, defaults last.
from dataclasses import dataclass
@dataclass
class Item:
name: str
price: float = 0.0
print(Item("pen"))name (required) precedes price (default)
Why: Now __init__ is (name, price=0.0) - legal. Real output: Item(name='pen', price=0.0).
| call | repr |
|---|---|
| Item("pen") | Item(name='pen', price=0.0) |
Worked example
from dataclasses import dataclass
@dataclass
class Player:
name: str
score: int = 0
p = Player("Ana")
p.score = 10
print(p)A dataclass instance is a normal object
Why: You can assign to its fields after creation, just like any attribute.
The change sticks
Why: Verified by execution: the repr reflects the new score. (frozen=True would block this - Part 6.)
| step | p.score | repr |
|---|---|---|
| Player("Ana") | 0 | Player(name='Ana', score=0) |
| p.score = 10 | 10 | Player(name='Ana', score=10) |
Section
Part 5
Intuition
A default value is created once, when the class is defined - not once per instance. For an immutable default like 0 that is fine; every instance just reads the same 0.
But a list can be changed. If every instance shared one list, appending to one record's list would silently change everyone's. That is the bug dataclasses refuse to let you write.
Concept
Writing members: list = [] does not raise a subtle bug later - @dataclass rejects it immediately with a ValueError, and tells you the fix.
default_factory — A zero-argument function (like list) that @dataclass calls to build a FRESH default for each new instance.
Matching
Match the pairs
Match each term to the definition this lesson gave it — not the one you would guess from the word.
Why: These are the working definitions of data record, @dataclass, default_factory as Session 28 - Dataclasses uses them. Pairing them correctly is the test of whether you could state each one with the slide switched off.
Anomaly
Predict first
A student writes this, and it looks reasonable:
Using an empty list directly as the default.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: It detects a mutable default and raises at class-creation time, pointing you at the fix instead of letting instances share one list.
Ask field() to make a fresh list each time.
Why: It detects a mutable default and raises at class-creation time, pointing you at the fix instead of letting instances share one list.
Trap
Using an empty list directly as the default.
from dataclasses import dataclass
@dataclass
class Team:
name: str
members: list = []@dataclass refuses to build the class
Why: It detects a mutable default and raises at class-creation time, pointing you at the fix instead of letting instances share one list.
| what happens | result |
|---|---|
| @dataclass sees members: list = [] | ValueError: mutable default <class 'list'> for field members is not allowed: use default_factory |
Ask field() to make a fresh list each time.
from dataclasses import dataclass, field
@dataclass
class Team:
name: str
members: list = field(default_factory=list)
print(Team("Red"))default_factory=list runs list() per instance
Why: Each new Team gets its own empty list. Real output: Team(name='Red', members=[]).
| call | repr |
|---|---|
| Team("Red") | Team(name='Red', members=[]) |
Cost model
Annotate
In Trap: a mutable default like [], before reading the notes: mark where the time actually goes. Which line dominates?
Faded example
Fill in the blanks
Each instance gets its own list, with the scaffolding fading: two lines are gone now — fill both.
from dataclasses import dataclass, field
@dataclass
class Team:
name: str
members: list = field(default_factory=list)
a = Team("Red")
b = Team("Blue")
a.members.append("Ana")
print(a)
print(b)
Why: Reproducing these unaided, rather than reading them, is what tells you the method has transferred. default_factory ran list() twice - once per Team.
Worked example
from dataclasses import dataclass, field
@dataclass
class Team:
name: str
members: list = field(default_factory=list)
a = Team("Red")
b = Team("Blue")
a.members.append("Ana")
print(a)
print(b)Two teams are built, each with a fresh list
Why: default_factory ran list() twice - once per Team.
Appending to a's list leaves b untouched
Why: Verified by execution: a has ['Ana'], b still has []. Separate lists, no shared-state bug.
| step | a.members | b.members |
|---|---|---|
| after construction | [] | [] |
| a.members.append("Ana") | ['Ana'] | [] |
Worked example
from dataclasses import dataclass, field
@dataclass
class Account:
owner: str
balance: float = 0.0
tags: list = field(default_factory=list)
acc = Account("Ana")
acc.tags.append("vip")
print(acc)Immutable defaults use =, mutable ones use field()
Why: balance=0.0 is safe as a plain default; tags needs default_factory=list.
Both defaults fill in for a one-argument call
Why: Verified by execution: balance is 0.0 and tags starts empty, then gets 'vip'.
| step | repr |
|---|---|
| Account("Ana") | Account(owner='Ana', balance=0.0, tags=[]) |
| acc.tags.append("vip") | Account(owner='Ana', balance=0.0, tags=['vip']) |
Comparison
Comparison matrix
From Mixing plain defaults and a factory: refill the repr column from what you know. The rest of the table is as it appeared.
| step | repr |
|---|---|
| Account("Ana") | Account(owner='Ana', balance=0.0, tags=[]) |
| acc.tags.append("vip") | Account(owner='Ana', balance=0.0, tags=['vip']) |
Explain it to yourself
Discussion prompt
In Immutable defaults are fine as-is this move is made:
A tuple cannot be changed, so sharing one is safe
Why is that legal? Name the rule or definition it rests on before you read on.
Hint: If you can only say "because that is what you do", the rule is the thing to go and find.
Answer:
Only mutable defaults (list, dict, set) are banned. A tuple, int, str, or None is allowed directly.
Worked example
from dataclasses import dataclass
@dataclass
class Point:
coords: tuple = (0, 0)
print(Point())A tuple cannot be changed, so sharing one is safe
Why: Only mutable defaults (list, dict, set) are banned. A tuple, int, str, or None is allowed directly.
No factory needed
Why: Verified by execution: the default tuple is used with no error.
| default type | allowed directly? |
|---|---|
| tuple (0, 0) | yes - immutable |
| list [] | no - use default_factory |
Section
Part 6
Concept
Pass @dataclass(frozen=True) and the generated class blocks assignment to its fields after construction. The record becomes read-only.
Use it for values that should never change once created - a config, a coordinate, a currency amount.
Worked example
from dataclasses import dataclass
@dataclass(frozen=True)
class Config:
host: str
port: int
c = Config("localhost", 8080)
print(c)
print(c.port)Construction and reading work normally
Why: frozen only blocks writes; you still build it and read its fields.
Read the output
Why: Verified by execution. The instance is fully usable - just not editable.
| line | output |
|---|---|
| print(c) | Config(host='localhost', port=8080) |
| print(c.port) | 8080 |
Anomaly
Predict first
A student writes this, and it looks reasonable:
Trying to edit a frozen instance in place.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: A frozen dataclass overrides __setattr__ to reject writes, so c.port = 9090 raises instead of quietly changing the record.
Build a new record with replace().
Why: A frozen dataclass overrides __setattr__ to reject writes, so c.port = 9090 raises instead of quietly changing the record.
Trap
Trying to edit a frozen instance in place.
from dataclasses import dataclass
@dataclass(frozen=True)
class Config:
host: str
port: int
c = Config("localhost", 8080)
c.port = 9090The assignment is blocked
Why: A frozen dataclass overrides __setattr__ to reject writes, so c.port = 9090 raises instead of quietly changing the record.
| statement | result |
|---|---|
| c.port = 9090 | dataclasses.FrozenInstanceError: cannot assign to field 'port' |
Build a new record with replace().
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Config:
host: str
port: int
c = Config("localhost", 8080)
c2 = replace(c, port=9090)
print(c2)replace copies c, changing only port
Why: The original c is untouched; c2 is a new frozen record. Real output: Config(host='localhost', port=9090).
| value | repr |
|---|---|
| c | Config(host='localhost', port=8080) |
| c2 | Config(host='localhost', port=9090) |
Two truths and a lie
Sort into buckets
Some of these hold up and some are the exact mistakes this lesson is built to prevent. Sort them.
x and y. A player with a name and score. A config with a host and port. These are just a few values traveling together.; To make a plain class usable, you hand-write __init__ to store the fields, __repr__ so printing is readable, and __eq__ so equal values compare equal.; Every data record needs the same trio: store the fields, print them, compare them. The names change; the shape never does.Concept
Because a frozen instance cannot change, Python can safely give it a __hash__. That means you can put frozen records in a set or use them as dict keys.
A normal (non-frozen) dataclass has no __hash__ - trying to add one to a set raises TypeError: unhashable type.
Pattern
Predict first
The table runs: Point(0, 0) | yes · Point(1, 2) | yes
In Frozen points in a set, given the rows so far: what is the next one — the row where added is Point(0, 0) again?
Correct: Point(0, 0) again | no - duplicate
| added | in set? |
|---|---|
| Point(0, 0) | yes |
| Point(1, 2) | yes |
| Point(0, 0) again | no - duplicate |
Why: The relationship between the columns, not the individual numbers, is what generates the next row. Point(0, 0) appears twice; a set drops duplicates using == and __hash__.
Worked example
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
s = {Point(0, 0), Point(1, 2), Point(0, 0)}
print(len(s))Three points go in, but two are equal
Why: Point(0, 0) appears twice; a set drops duplicates using == and __hash__.
The set keeps two distinct points
Why: Verified by execution: len is 2. Value equality plus hashing makes de-duplication just work.
| added | in set? |
|---|---|
| Point(0, 0) | yes |
| Point(1, 2) | yes |
| Point(0, 0) again | no - duplicate |
Section
Part 7
Concept
A dataclass shines when the object is mostly data: a handful of named fields you construct, compare, and print. That is the common case.
If a class is mostly behavior - lots of methods, complex state, no obvious fields - a plain class is clearer. Dataclass is not a hammer for every class.
Worked example
from dataclasses import dataclass
@dataclass
class Rectangle:
width: int
height: int
def area(self):
return self.width * self.height
r = Rectangle(3, 4)
print(r)
print(r.area())Fields are annotated; methods are normal defs
Why: @dataclass only generates from the annotated fields - it leaves your methods alone.
Both the free repr and your method work
Why: Verified by execution: repr shows the fields, area() computes 3 * 4.
| line | output |
|---|---|
| print(r) | Rectangle(width=3, height=4) |
| print(r.area()) | 12 |
Trade off
Comparison matrix
From You can still add methods: every row here is a choice with a cost. Fill the output column, then say which row you would actually pick and what you give up for it.
| line | output |
|---|---|
| print(r) | Rectangle(width=3, height=4) |
| print(r.area()) | 12 |
Intuition
You could store a point as {"x": 1, "y": 2}. But a dict has no fixed shape, no type hints, and any typo like pt["z"] fails only at runtime.
A dataclass names the fields once, documents their types, and gives you a real class you can add methods to. Prefer it over a dict when the shape is fixed and known.
Explain it
Discussion prompt
Explain Dataclass vs a plain dict to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.
Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.
Answer:
You could store a point as {"x": 1, "y": 2}. But a dict has no fixed shape, no type hints, and any typo like pt["z"] fails only at runtime.
Concept
When you do need a plain dict - to save as JSON, say - dataclasses.asdict(obj) converts a dataclass instance into {field: value}.
Analogy
Discussion prompt
Explain asdict turns a record back into a dict by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.
Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.
Answer:
When you do need a plain dict - to save as JSON, say - dataclasses.asdict(obj) converts a dataclass instance into {field: value}.
Worked example
from dataclasses import dataclass, asdict
@dataclass
class Point:
x: int
y: int
print(asdict(Point(1, 2)))asdict reads the fields into a dict
Why: Each field name becomes a key; each field value becomes the value.
Read the output
Why: Verified by execution. Handy right before json.dumps(...).
| expression | output |
|---|---|
| asdict(Point(1, 2)) | {'x': 1, 'y': 2} |
Blank canvas
Draw it
Draw what From dataclass to dict just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.
Section
Part 8
Ranking
Put in order
Put the moves of Building a roster of players into the order they have to happen.
Why: These are the moves of the worked example in the order it makes them, and each one is set up by the one before it. default_factory=list gives each Player a separate empty list; score defaults to 0.
Worked example
from dataclasses import dataclass, field
@dataclass
class Player:
name: str
score: int = 0
items: list = field(default_factory=list)
roster = []
for n in ["Ana", "Ben", "Cy"]:
roster.append(Player(n))
roster[0].score = 10
roster[0].items.append("sword")
roster[1].score = 5
for p in roster:
print(p)The loop builds three players, each with its own items list
Why: default_factory=list gives each Player a separate empty list; score defaults to 0.
Then a few edits change only their own records
Why: Setting roster[0].score and appending to roster[0].items touch Ana alone; roster[1] gets its own score.
Print each player's final state
Why: Verified by execution. Ben's items stayed empty - no shared-list bug - and Cy is untouched.
| player | score | items |
|---|---|---|
| Ana | 10 | ['sword'] |
| Ben | 5 | [] |
| Cy | 0 | [] |
Comparison
Comparison matrix
From Building a roster of players: refill the score column from what you know. The rest of the table is as it appeared.
| player | score | items |
|---|---|---|
| Ana | 10 | ['sword'] |
| Ben | 5 | [] |
| Cy | 0 | [] |
Concept
The generated __init__ requires every field that has no default. Leave one out and you get the same error any function gives for a missing argument.
Counterexample
Discussion prompt
The generated __init__ requires every field that has no default. Leave one out and you get the same error any function gives for a missing argument.
That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.
Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.
Fill the middle
Fill in the blanks
From Forgetting a required field — one line has had its right-hand side removed. Put it back.
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
p = Point(1)
Why: p is what everything below it consumes, so the wrong expression here fails later and somewhere else. Neither field has a default, so both are required positional arguments of the generated __init__.
Worked example
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
p = Point(1)Point needs both x and y
Why: Neither field has a default, so both are required positional arguments of the generated __init__.
One argument short raises
Why: Verified by execution: the message names the missing field, y.
| call | result |
|---|---|
| Point(1) | TypeError: Point.__init__() missing 1 required positional argument: 'y' |
Error analysis
Annotate
Walk the callouts on Forgetting a required field. Each one is a place this is easy to get subtly wrong.
Section
Part 9
Pattern
1. from dataclasses import dataclass, then put @dataclass above class
Why: The decorator reads your fields and generates __init__, __repr__, __eq__.
2. List each field as name: type
Why: No self, no __init__ - the annotation is what the decorator turns into a parameter.
3. Required fields first, then fields with = defaults
Why: A defaulted field before a required one raises TypeError at class-creation time.
4. For a list/dict/set default, use field(default_factory=...)
Why: A bare mutable default raises ValueError; the factory makes a fresh one per instance.
Pattern
Should this record ever change after creation?
Why: No - use @dataclass(frozen=True). You also get hashing, so it works in sets and dict keys.
Is a field's default mutable (list, dict, set)?
Why: Yes - field(default_factory=list). No (int, str, tuple, None) - a plain = default is fine.
Need a plain dict out (for JSON)?
Why: asdict(obj) converts the record to {field: value}.
Real world
Discussion prompt
Outside this lesson: where does Session 28 - Dataclasses actually turn up? Name one concrete situation — a job, a piece of software someone ships, a decision somebody has to make — and say which part of Choosing frozen and default_factory is doing the work in it.
Hint: Vague is the failure mode here. "Engineering" is not a situation; "deciding whether this build is fast enough to ship" is.
Answer:
Session 28 of the Python Fundamentals series, in depth. The @dataclass decorator writes the boilerplate a plain class needs by hand: __init__, a readable __repr__, and value-based __eq__.
Check
What does printing show?
from dataclasses import dataclass
@dataclass
class Dog:
name: str
age: int
print(Dog("Rex", 3))| fields | repr |
|---|---|
| name='Rex', age=3 | ? |
Check your understanding
What does this print?
Answer: A
Why: @dataclass generates a __repr__ of the form ClassName(field=value, ...), so it prints Dog(name='Rex', age=3). Verified by execution.
Check
Same values, two objects.
from dataclasses import dataclass
@dataclass
class P:
x: int
print(P(5) == P(5))| a | b | a == b |
|---|---|---|
| P(5) | P(5) | ? |
Check your understanding
What does this print?
Answer: A
Why: The generated __eq__ compares field by field. Both are class P with x=5, so they are equal and it prints True. Verified by execution.
Elimination
Eliminate the wrong options
What happens when this class is defined?
3 of these 4 are wrong. Strike them one at a time, and say what rules each one out before you strike the next. The survivor is the answer.
Survives elimination: A
Why: @dataclass detects a mutable default and raises ValueError at class-creation time, telling you to use field(default_factory=list). Verified by execution.
Check
Spot the outcome.
from dataclasses import dataclass
@dataclass
class Cart:
items: list = []| default | result |
|---|---|
| items: list = [] | ? |
Check your understanding
What happens when this class is defined?
Answer: A
Why: @dataclass detects a mutable default and raises ValueError at class-creation time, telling you to use field(default_factory=list). Verified by execution.
Check
Two carts, one append.
from dataclasses import dataclass, field
@dataclass
class Cart:
items: list = field(default_factory=list)
a = Cart()
b = Cart()
a.items.append("apple")
print(b.items)| a.items | b.items |
|---|---|
| ['apple'] | ? |
Check your understanding
What does print(b.items) show?
Answer: A
Why: default_factory=list builds a separate empty list for each Cart, so appending to a.items does not touch b.items - it stays []. Verified by execution.
Check
The record is frozen.
from dataclasses import dataclass
@dataclass(frozen=True)
class P:
x: int
p = P(1)
p.x = 2| statement | result |
|---|---|
| p.x = 2 | ? |
Check your understanding
What does p.x = 2 do?
Answer: A
Why: A frozen dataclass blocks attribute assignment via __setattr__, so p.x = 2 raises FrozenInstanceError. Use replace(p, x=2) to get a new record instead. Verified by execution.
Elimination
Eliminate the wrong options
What happens when this class is defined?
3 of these 4 are wrong. Strike them one at a time, and say what rules each one out before you strike the next. The survivor is the answer.
Survives elimination: A
Why: The generated __init__ would place required name after defaulted price, which Python forbids, so it raises TypeError at class-creation time. Put required fields first. Verified by execution.
Check
A default before a required field.
from dataclasses import dataclass
@dataclass
class Item:
price: float = 0.0
name: str| order | result |
|---|---|
| default, then required | ? |
Check your understanding
What happens when this class is defined?
Answer: A
Why: The generated __init__ would place required name after defaulted price, which Python forbids, so it raises TypeError at class-creation time. Put required fields first. Verified by execution.
Check
The second argument is left out.
from dataclasses import dataclass
@dataclass
class Player:
name: str
score: int = 0
print(Player("Ana"))| call | repr |
|---|---|
| Player("Ana") | ? |
Check your understanding
What does this print?
Answer: A
Why: score has a default of 0, so omitting it uses 0, and the repr shows both fields: Player(name='Ana', score=0). Verified by execution.
Connect it up
Draw it
One page, no notation unless you need it: draw how these connect — The Boilerplate Problem · The @dataclass Decorator · The repr and __eq__ You Get Free · Default Values · Mutable Defaults · frozen: Read-Only Records. Put an arrow wherever one of them is what makes another possible, and label the arrow with why.
Recap
@dataclass turns a list of type-annotated fields into a class with a generated __init__, a readable __repr__, and a value-based __eq__ - no boilerplate.
| You write | It means |
|---|---|
| @dataclass | generate __init__, __repr__, __eq__ from the fields |
| x: int | a required field named x |
| score: int = 0 | an optional field, defaults to 0 |
| items: list = field(default_factory=list) | a fresh list per instance |
| @dataclass(frozen=True) | read-only, hashable record |
| asdict(obj) | convert the record to a plain dict |
Reach for a dataclass for plain data records. Watch the three traps: bare mutable defaults (ValueError), a default field before a required one (TypeError), and writing to a frozen instance (FrozenInstanceError).
Want this taught 1-on-1? Alexander tutors Python Fundamentals — $55/session, free consultation.