Session 28 - Dataclasses

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

What this lesson covers

The lesson, slide by slide

1. Dataclasses

Title

Python Fundamentals - Session 28

One decorator writes the boilerplate every data record needs

2. What you will be able to do

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:

  1. Turn a plain data class into a @dataclass with type-annotated fields.
  2. Explain the three methods you get free: __init__, __repr__, __eq__.
  3. Give fields default values, and use default_factory for lists.
  1. Make a record read-only with frozen=True.
  2. Avoid the mutable-default ValueError and the field-order TypeError.
  3. Decide when a dataclass is the right tool - and when it is not.

3. What survived from Session 27 - classmethod, staticmethod & property?

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.

4. The Boilerplate Problem

Section

Part 1

5. Programs are full of little data records

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.

6. Break it if you can: Programs are full of little data records

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.

7. A plain class makes you write three methods

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.

8. By analogy: A plain class makes you write three methods

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.

9. Restore the missing line: A hand-written class prints unhelpfully

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.

10. A hand-written class prints unhelpfully

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.

lineoutput
print(p.x, p.y)1 2
print(p)<__main__.Point object at 0x...>

11. Fill in: output for A hand-written class prints unhelpfully

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.

lineoutput
print(p.x, p.y)1 2
print(p)<__main__.Point object at 0x...>

12. Finish it with less help: And two equal points are not equal

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.

13. And two equal points are not equal

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.

expressionvalue
a == bFalse

14. Inspect it line by line: And two equal points are not equal

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.

  • Both were built as Point(1, 2), so field-by-field they match.
  • With no __eq__, Python falls back to identity - are they the SAME object? They are not, so it prints False.

15. You keep rewriting the same three methods

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.

16. Teach it back: You keep rewriting the same three methods

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.

17. The @dataclass Decorator

Section

Part 2

18. One decorator writes the boilerplate

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.

19. Think of it as a code generator

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.

20. Fields are type-annotated names

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.

21. The same Point as a dataclass

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.

lineoutput
print(p)Point(x=1, y=2)
print(p.x, p.y)1 2

22. What each one costs: The same Point as a dataclass

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.

lineoutput
print(p)Point(x=1, y=2)
print(p.x, p.y)1 2

23. Equality now compares values

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.

expressionvalue
a == bTrue
a == cFalse

24. Draw the shape of it: Equality now compares values

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.

25. The repr and __eq__ You Get Free

Section

Part 3

26. A repr you can actually read

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.

27. Readable even inside a list

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.

expressionoutput
print(pts)[Point(x=0, y=0), Point(x=1, y=2)]

28. Equality by value, not by identity

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.

29. Where does each piece belong: Session 28 - Dataclasses

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.

The Boilerplate Problem
Programs are full of little data records; A plain class makes you write three methods; A hand-written class prints unhelpfully
The @dataclass Decorator
One decorator writes the boilerplate; Think of it as a code generator; Fields are type-annotated names
The repr and __eq__ You Get Free
A repr you can actually read; Readable even inside a list; Equality by value, not by identity
s1
The Boilerplate Problem is where Session 28 - Dataclasses puts Programs are full of little data records, A plain class makes you write three methods, A hand-written class prints unhelpfully. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.
s2
The @dataclass Decorator is where Session 28 - Dataclasses puts One decorator writes the boilerplate, Think of it as a code generator, Fields are type-annotated names. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.
s3
The repr and __eq__ You Get Free is where Session 28 - Dataclasses puts A repr you can actually read, Readable even inside a list, Equality by value, not by identity. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.

30. in searches by value

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.

expressionvalue
Point(1, 2) in ptsTrue

31. Different classes are never equal

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.

expressionvalue
Point(1, 2) == Pixel(1, 2)False

32. Default Values

Section

Part 4

33. Give a field a default with =

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.

34. Restore the missing line: A player with a default score

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.

35. A player with a default score

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.

callscore usedrepr
Player("Ana")0 (default)Player(name='Ana', score=0)
Player("Ben", 50)50Player(name='Ben', score=50)

36. Defaults must come after required fields

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.

37. Something is wrong here: a default field before a required one

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.

38. Trap: a default field before a required one

Trap

The trap

The field with a default is listed first.

from dataclasses import dataclass

@dataclass
class Item:
    price: float = 0.0
    name: str

name 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 happensresult
@dataclass builds __init__TypeError: non-default argument 'name' follows default argument

The fix

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).

callrepr
Item("pen")Item(name='pen', price=0.0)

39. Fields stay mutable (unless frozen)

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.)

stepp.scorerepr
Player("Ana")0Player(name='Ana', score=0)
p.score = 1010Player(name='Ana', score=10)

40. Mutable Defaults

Section

Part 5

41. Why one shared list is a trap

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.

42. A bare list default is banned

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.

43. Term to definition: Session 28 - Dataclasses

Matching

Match the pairs

Match each term to the definition this lesson gave it — not the one you would guess from the word.

  • t1. data record
  • t2. @dataclass
  • t3. default_factory
  • d1. An object whose whole job is to hold a fixed set of named values. It has data, and little or no behavior.
  • d2. A decorator that inspects a class's annotated fields and auto-writes __init__, __repr__, and __eq__ so you do not have to.
  • d3. A zero-argument function (like list) that @dataclass calls to build a FRESH default for each new instance.

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.

44. Something is wrong here: a mutable default like []

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.

45. Trap: a mutable default like []

Trap

The 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 happensresult
@dataclass sees members: list = []ValueError: mutable default <class 'list'> for field members is not allowed: use default_factory

The fix

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=[]).

callrepr
Team("Red")Team(name='Red', members=[])

46. Where the cost goes: Trap: a mutable default like []

Cost model

Annotate

In Trap: a mutable default like [], before reading the notes: mark where the time actually goes. Which line dominates?

  • It detects a mutable default and raises at class-creation time, pointing you at the fix instead of letting instances share one list.
  • Each new Team gets its own empty list. Real output: Team(name='Red', members=[]).

47. Finish it with less help: Each instance gets its own list

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.

48. Each instance gets its own list

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.

stepa.membersb.members
after construction[][]
a.members.append("Ana")['Ana'][]

49. Mixing plain defaults and a factory

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'.

steprepr
Account("Ana")Account(owner='Ana', balance=0.0, tags=[])
acc.tags.append("vip")Account(owner='Ana', balance=0.0, tags=['vip'])

50. Fill in: repr for Mixing plain defaults and a factory

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.

steprepr
Account("Ana")Account(owner='Ana', balance=0.0, tags=[])
acc.tags.append("vip")Account(owner='Ana', balance=0.0, tags=['vip'])

51. Why is this step legal: A tuple cannot be changed, so sharing one is safe

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.

52. Immutable defaults are fine as-is

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 typeallowed directly?
tuple (0, 0)yes - immutable
list []no - use default_factory

53. frozen: Read-Only Records

Section

Part 6

54. frozen=True locks the instance

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.

55. A frozen config

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.

lineoutput
print(c)Config(host='localhost', port=8080)
print(c.port)8080

56. Something is wrong here: assigning to a frozen field

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.

57. Trap: assigning to a frozen field

Trap

The 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 = 9090

The assignment is blocked

Why: A frozen dataclass overrides __setattr__ to reject writes, so c.port = 9090 raises instead of quietly changing the record.

statementresult
c.port = 9090dataclasses.FrozenInstanceError: cannot assign to field 'port'

The fix

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).

valuerepr
cConfig(host='localhost', port=8080)
c2Config(host='localhost', port=9090)

58. Which of these survive contact with Session 28 - Dataclasses?

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.

Holds up
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.; 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.
Breaks
The field with a default is listed first.; Using an empty list directly as the default.
sound
These are stated as this lesson states them — each one survives the edge cases Session 28 - Dataclasses puts it through.
flawed
Each of these is lifted from a trap in this deck: reasonable-sounding, and wrong in a way that only shows up once you rely on it.

59. Frozen records are hashable

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.

60. Predict the next row: Frozen points in a set

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

addedin set?
Point(0, 0)yes
Point(1, 2)yes
Point(0, 0) againno - 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__.

61. Frozen points in a set

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.

addedin set?
Point(0, 0)yes
Point(1, 2)yes
Point(0, 0) againno - duplicate

62. When a Dataclass Is the Right Tool

Section

Part 7

63. Reach for it for plain data records

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.

64. You can still add methods

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.

lineoutput
print(r)Rectangle(width=3, height=4)
print(r.area())12

65. What each one costs: You can still add methods

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.

lineoutput
print(r)Rectangle(width=3, height=4)
print(r.area())12

66. Dataclass vs a plain dict

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.

67. Teach it back: Dataclass vs a plain dict

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.

68. asdict turns a record back into a dict

Concept

When you do need a plain dict - to save as JSON, say - dataclasses.asdict(obj) converts a dataclass instance into {field: value}.

69. By analogy: asdict turns a record back into a dict

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}.

70. From dataclass to dict

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(...).

expressionoutput
asdict(Point(1, 2)){'x': 1, 'y': 2}

71. Draw the shape of it: From dataclass to dict

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.

72. A Fuller Trace

Section

Part 8

73. What has to happen first: Building a roster of players

Ranking

Put in order

Put the moves of Building a roster of players into the order they have to happen.

  1. The loop builds three players, each with its own items list
  2. Then a few edits change only their own records
  3. Print each player's final state

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.

74. Building a roster of players

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.

playerscoreitems
Ana10['sword']
Ben5[]
Cy0[]

75. Fill in: score for Building a roster of players

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.

playerscoreitems
Ana10['sword']
Ben5[]
Cy0[]

76. Missing a required field is a TypeError

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.

77. Break it if you can: Missing a required field is a TypeError

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.

78. Restore the missing line: Forgetting a required field

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__.

79. Forgetting a required field

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.

callresult
Point(1)TypeError: Point.__init__() missing 1 required positional argument: 'y'

80. Inspect it line by line: Forgetting a required field

Error analysis

Annotate

Walk the callouts on Forgetting a required field. Each one is a place this is easy to get subtly wrong.

  • Neither field has a default, so both are required positional arguments of the generated __init__.
  • Verified by execution: the message names the missing field, y.

81. Patterns & Checks

Section

Part 9

82. Writing a dataclass

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.

83. Choosing frozen and default_factory

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}.

84. Where this shows up: Session 28 - Dataclasses

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__.

85. Check: the free repr

Check

What does printing show?

from dataclasses import dataclass

@dataclass
class Dog:
    name: str
    age: int

print(Dog("Rex", 3))
fieldsrepr
name='Rex', age=3?

Check your understanding

What does this print?

  • A. Dog(name='Rex', age=3) (correct)
  • B. <__main__.Dog object at 0x...>
  • C. {'name': 'Rex', 'age': 3}
  • D. Dog('Rex', 3)

Answer: A

Why: @dataclass generates a __repr__ of the form ClassName(field=value, ...), so it prints Dog(name='Rex', age=3). Verified by execution.

Why B tempts people
That is the default object repr you get WITHOUT a __repr__. @dataclass writes one for you, so you get the readable form.
Why C tempts people
A dataclass is not a dict. asdict(Dog('Rex', 3)) would give that dict, but printing the object shows its repr.
Why D tempts people
The generated repr includes the field NAMES (name=, age=), not just the bare values.

86. Check: value equality

Check

Same values, two objects.

from dataclasses import dataclass

@dataclass
class P:
    x: int

print(P(5) == P(5))
aba == b
P(5)P(5)?

Check your understanding

What does this print?

  • A. True (correct)
  • B. False
  • C. None
  • D. TypeError

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.

Why B tempts people
False is what a PLAIN class prints (identity comparison). A dataclass generates __eq__, so equal values compare True.
Why C tempts people
== always returns a bool here, never None.
Why D tempts people
Comparing two P instances is valid; the generated __eq__ handles it with no error.

87. Rule out three: Check: the mutable default

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.

  • A. ValueError: mutable default ... is not allowed: use default_factory
  • B. Nothing - every Cart shares one list
  • C. Each Cart quietly gets its own new list
  • D. SyntaxError

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.

88. Check: the mutable default

Check

Spot the outcome.

from dataclasses import dataclass

@dataclass
class Cart:
    items: list = []
defaultresult
items: list = []?

Check your understanding

What happens when this class is defined?

  • A. ValueError: mutable default ... is not allowed: use default_factory (correct)
  • B. Nothing - every Cart shares one list
  • C. Each Cart quietly gets its own new list
  • D. SyntaxError

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.

Why B tempts people
That silent shared-list bug is exactly what @dataclass PREVENTS by raising instead of allowing the bare list.
Why C tempts people
You only get a fresh per-instance list with field(default_factory=list); a bare [] is rejected before that can happen.
Why D tempts people
The syntax is valid Python; the error is a runtime ValueError raised by the decorator, not a SyntaxError.

89. Check: default_factory in action

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.itemsb.items
['apple']?

Check your understanding

What does print(b.items) show?

  • A. [] (correct)
  • B. ['apple']
  • C. None
  • D. TypeError

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.

Why B tempts people
That would happen only if a and b shared one list. default_factory gives each instance its own, so b stays empty.
Why C tempts people
The field defaults to an empty list, not None, so b.items is [].
Why D tempts people
Appending and printing are both valid; no error occurs.

90. Check: assigning to a frozen record

Check

The record is frozen.

from dataclasses import dataclass

@dataclass(frozen=True)
class P:
    x: int

p = P(1)
p.x = 2
statementresult
p.x = 2?

Check your understanding

What does p.x = 2 do?

  • A. Raises FrozenInstanceError: cannot assign to field 'x' (correct)
  • B. Sets x to 2 successfully
  • C. Raises ValueError
  • D. Silently does nothing

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.

Why B tempts people
frozen=True is exactly what prevents the assignment from succeeding.
Why C tempts people
The error is a FrozenInstanceError (from dataclasses), not a ValueError.
Why D tempts people
It does not fail quietly - it raises an exception that stops the program unless caught.

91. Rule out three: Check: field order

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.

  • A. TypeError: non-default argument 'name' follows default argument
  • B. It works - name just becomes required
  • C. ValueError about mutable defaults
  • D. name silently gets a default too

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.

92. Check: field order

Check

A default before a required field.

from dataclasses import dataclass

@dataclass
class Item:
    price: float = 0.0
    name: str
orderresult
default, then required?

Check your understanding

What happens when this class is defined?

  • A. TypeError: non-default argument 'name' follows default argument (correct)
  • B. It works - name just becomes required
  • C. ValueError about mutable defaults
  • D. name silently gets a default too

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.

Why B tempts people
Python will not build an __init__ with a required parameter after a defaulted one - it raises rather than accepting it.
Why C tempts people
No mutable default is involved here; 0.0 is immutable. The error is about argument ORDER, not mutability.
Why D tempts people
name has no default written, so Python does not invent one - it rejects the ordering instead.

93. Check: omitting a defaulted field

Check

The second argument is left out.

from dataclasses import dataclass

@dataclass
class Player:
    name: str
    score: int = 0

print(Player("Ana"))
callrepr
Player("Ana")?

Check your understanding

What does this print?

  • A. Player(name='Ana', score=0) (correct)
  • B. TypeError: missing argument 'score'
  • C. Player(name='Ana')
  • D. Player(name='Ana', score=None)

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.

Why B tempts people
score is not required - it has a default, so leaving it out is fine and raises nothing.
Why C tempts people
The repr always lists every field, including defaulted ones, so score=0 appears.
Why D tempts people
The default is 0, not None - that is the value written after the = in the field.

94. Connect it up: Session 28 - Dataclasses

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.

95. What you can do now

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 writeIt means
@dataclassgenerate __init__, __repr__, __eq__ from the fields
x: inta required field named x
score: int = 0an 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).

Sources

  1. Python 3 docs - dataclasses
  2. Python 3 docs - dataclasses.field and default_factory
  3. Python 3 docs - frozen instances and FrozenInstanceError
  4. All snippets and error messages executed and copied from CPython 3.12. — Author verification run, 2026-07-15 (Python Fundamentals series, Session 28).

Want this taught 1-on-1? Alexander tutors Python Fundamentals — $55/session, free consultation.

Book on Wyzant · Text (657) 465-8108