All 24 modules are open from the start — nothing here is
locked, and nothing costs anything. Sign in so your progress, titles and
credentials stay with you, on every device you use.
Free forever, with your Google account. No password, no payment.
Saying the shape once, and having Python write the rest
The class you wrote in the last module was thirty lines, and twenty-five
of them were __init__, __repr__ and __eq__
doing exactly what anyone would have guessed. A dataclass writes those for you
from a list of fields. Type hints are the other half: a way of saying what a
value is supposed to be, checked by tools rather than by Python.
Ready?
1
Fields In, Three Methods Out
A dataclass is an ordinary class with the boring methods generated from
the fields you list:
from dataclasses import dataclass
@dataclass
class Order:
name: str
quantity: int
price: int
order = Order("tea", 3, 1250)
order # Order(name='tea', quantity=3, price=1250)
order == Order("tea", 3, 1250) # True
That is __init__, __repr__ and
__eq__, all three of which you wrote by hand in the last
module, and all three exactly as you wrote them.
The annotations are what makes a field a field. A line without one is a
plain class attribute and will not appear in the generated
__init__, which is a genuinely confusing way to lose a
parameter.
Everything else about a class still works. Methods, properties and
@property all behave normally — a dataclass only replaces
the parts that were predictable.
Not for everything
A dataclass is right when the class is mostly a named shape with some
behaviour attached. When there is real logic in
__init__ — building things, opening things, deciding
things — a normal class says so, and a dataclass with a large
__post_init__ is a normal class wearing a decorator.
Quick check
What does @dataclass generate?
2
Defaults, and the One Python Refuses
A field can have a default, and the same ordering rule applies as for
function parameters: everything with a default comes last.
@dataclass
class Order:
name: str
quantity: int
price: int = 0
express: bool = False
A mutable default is a different matter. Python does not let you
make the module 3-02 mistake here at all:
@dataclass
class Item:
tags: list = [] # ValueError, at class definition time
# ValueError: mutable default <class 'list'> for field tags
# is not allowed: use default_factory
This is the only place in the language that catches it for you, and the
error even names the fix:
from dataclasses import dataclass, field
@dataclass
class Item:
name: str
tags: list[str] = field(default_factory=list)
default_factory takes the function that makes the
default — list, not list() — and calls it once
per instance. Exactly the defaultdict pattern, and exactly
the fix for the shared class attribute from the last module.
Quick check
Why does tags: list = [] raise at definition time?
3
frozen, order, and Validating After Init
frozen=True makes instances immutable. Assigning to a field
raises FrozenInstanceError, and — because the object can no
longer change — instances become hashable, so they can be dictionary keys
and set members.
@dataclass(frozen=True)
class Point:
x: int
y: int
seen = {Point(1, 2)} # works; a normal dataclass could not
This is the tuple argument from module 2-06, with names on the fields. Use
it for anything that represents a value rather than a thing with a life
of its own.
order=True generates <, > and
friends, comparing fields in declaration order — so the field you want to
sort by goes first, and the rest break ties. Instances then sort with no
key at all.
And __post_init__ runs immediately after the generated
__init__, which is where validation goes:
def __post_init__(self):
if self.quantity < 0:
raise ValueError(f"quantity must not be negative: {self.quantity}")
Quick check
Why can a frozen dataclass be a dictionary key when an ordinary one cannot?
4
Hints Are for People and Tools, Not for Python
A type hint says what a value is supposed to be. Python records it and
does nothing else with it:
def line_total(quantity: int, price: int) -> int:
return quantity * price
line_total("3", "4") # "3333" — no error, no complaint
Nothing is checked at runtime, ever. Hints exist so that a
type checker — mypy, pyright, your editor — can find
that mistake before the code runs, and so a reader can tell what a
function expects without following it three levels down.
The syntax worth knowing:
names: list[str]
counts: dict[str, int]
pairs: tuple[str, int]
maybe: str | None # either a string or nothing
def f(x: int) -> None: # returns nothing useful
str | None is the one that earns its keep. It says out loud
that a value can be missing, and a checker will then insist you deal with
that before using it — which catches the class of bug where a
None travels three functions before failing.
Function signatures
where a reader looks first, and where a wrong assumption costs most.
Dataclass fields
required anyway, and they are the shape of your data.
Anything that can be None
the single most useful thing a hint can tell you.
Every local variable
count: int = 0 is noise. The value says it.
A hint is a claim, not a guarantee
-> int does not stop a function returning a string. It
only means somebody said it would not, and that a checker can now
disagree with them. Without a checker in the build, hints are
documentation that cannot go out of date silently — which is still
worth something, and much less than it sounds.
Validate at the boundary with real code. Use hints to say what you
meant.
Quick check
What happens at runtime when a function hinted -> int returns a string?
0 of 9 completed
Real Python runs right here in your browser — nothing to install, nothing
sent to a server. The interpreter downloads once the first time you press
Run, then stays cached.
01
To do
@dataclass generates __init__,
__repr__ and __eq__ from a list of annotated
fields — the three you wrote by hand in the last module, exactly as you
wrote them.
from dataclasses import dataclass
@dataclass class Order: name: str quantity: int
The annotation is what makes a field a field. A line without one is a
plain class attribute and will not appear in the generated
__init__ at all.
Your task: replace the hand-written class with a
dataclass holding the same three fields. The output must not change:
Import dataclass, put @dataclass above the class, and replace the whole body with three annotated lines: name: str, quantity: int, price: int.
Output
02
To do
A field can have a default, and the same ordering rule applies as for
function parameters: everything with a default comes after everything
without one.
@dataclass class Order: name: str quantity: int = 1 express: bool = False
Your task: the fields below are in an order Python
rejects. Reorder them so the class defines, keeping every default, then
print three orders:
name has no default, so it has to come first. The other three keep their defaults and their relative order: quantity, price, express.
Output
03
To do
This is the only place in the language that stops you making the mutable
default mistake. Run it and read the error — it names the fix.
ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory
field(default_factory=list) takes the function that
makes the default — list, with no brackets — and calls it
once per instance. The same shape as defaultdict(list), and
the fix for the shared class attribute from the last module.
Your task: fix the field so each item gets its own list:
['hot'] [] Item(name='tea', tags=['hot'])
your_code.py
PythonCtrl↵ to run
Hint
Import field alongside dataclass, then write tags: list[str] = field(default_factory=list). Pass the list type itself, not a list.
Output
04
To do
frozen=True makes instances immutable: assigning to a field
raises FrozenInstanceError. And because the object can no
longer change, it becomes hashable — so it can be a dictionary key or a
set member, which an ordinary dataclass cannot.
This is the tuple argument from module 2-06 with names on the fields. Use
it for anything that represents a value rather than a thing with a life
of its own.
Your task: make Point frozen, so the
assignment is refused and the set works:
Point(x=1, y=2) cannot assign to field 'x' 2 True
your_code.py
PythonCtrl↵ to run
Hint
One change: @dataclass(frozen=True). Everything else already works once the class is frozen.
Output
05
To do
order=True generates the comparison methods, comparing
fields in declaration order. So the field you want to
sort by goes first, and the rest break ties.
Instances then sort with no key argument at all, and
min and max work on them too.
Your task: make Result ordered and put the
score first, so the results sort lowest score first:
Two changes: @dataclass(order=True), and swap the two fields so score is declared first. The three calls at the bottom then need their arguments in the new order.
Output
06
To do
__post_init__ runs immediately after the generated
__init__, with every field already assigned. That is where
validation goes in a dataclass.
def __post_init__(self): if self.quantity < 0: raise ValueError(f"quantity must not be negative: {self.quantity}")
Your task: add validation refusing a negative quantity
and a price of zero or less, with the offending value in each message:
Order(name='tea', quantity=3, price=1250) quantity must not be negative: -2 price must be positive: 0
your_code.py
PythonCtrl↵ to run
Hint
def __post_init__(self): with the two checks inside, each raising ValueError with an f-string naming the value. It takes only self — the fields are already assigned by then.
Output
07
To do
Python records annotations and does nothing else with them. Nothing is
checked at runtime, ever.
Hints exist so a type checker — mypy, pyright, your editor — can find
that before the code runs, and so a reader can tell what a function
expects. When you need the guarantee at runtime, that is validation, and
it is ordinary code.
Your task: add hints to line_total, then
show both halves: the wrong-typed call that Python allows, and a
checked_total that validates and refuses it.
3750 3333 quantity must be a whole number, got '3'
your_code.py
PythonCtrl↵ to run
Hint
Annotate line_total as (quantity: int, price: int) -> int and watch it still accept a string where an int was promised — "3" times 4 is "3333". In checked_total, use isinstance(quantity, int) and raise TypeError(f"quantity must be a whole number, got {quantity!r}") — the !r is what puts the quotes round the '3'.
Output
08
To do
A bare list says almost nothing. list[str] says
what is in it, which is the part a reader actually needs.
str | None is the one that earns its keep: it says out loud
that a value can be missing, and a checker will then insist you deal with
that before using it.
Your task: annotate summarise, which takes a
list of names and returns a dictionary of name to length, and
first_long, which returns the first name over four
characters or None when there is none:
{'tea': 3, 'whisk': 5, 'cloth': 5} whisk None
your_code.py
PythonCtrl↵ to run
Hint
summarise takes names: list[str] and returns dict[str, int]. first_long takes the same and returns str | None, because it genuinely might not find one.
Output
09
To do
A dataclass replaces only the predictable methods. Everything else about
a class works exactly as before — methods, properties, the lot.
Your task: take the dataclass below and add a
total property and an add_note method, with the
notes list built per instance:
3750 12500 ['checked'] []
The last two lines are one order's notes after adding one, and a second
order's, which must still be empty.
your_code.py
PythonCtrl↵ to run
Hint
notes: list[str] = field(default_factory=list) as the fourth field. Then @property above def total(self), and def add_note(self, text) appending to self.notes with no return.
Output
The typed inventory
To do
Two dataclasses and a function, fully annotated: one frozen value type, one
record with validation and behaviour, and a report that sorts without a
key.
Write Sku: a frozen dataclass
with code: str and region: str. Frozen because it
identifies a thing rather than being one, and because the report uses it as a
dictionary key.
Write Item: an ordered dataclass
whose fields are, in this order:
value: int — quantity times price, filled in by __post_init__. Declared first so items sort by it.
name: str
quantity: int
price: int
tags: list[str] — its own list per item, defaulting to empty
__post_init__ sets value, and raises
ValueError naming the offending number when the quantity is
negative or the price is zero or less. Callers pass 0 for
value and let the class work it out.
Write index(items) -> dict[Sku, Item]: fully
annotated, keyed by a Sku built from the item name and the
region it is given.
Then print exactly five lines:
Item(value=2250, name='cloth', quantity=5, price=450, tags=[]) [2250, 3750, 11880] Sku(code='tea', region='eu') True price must be positive: 0
In order: the cheapest item, every item's value sorted ascending, one key
from the index, whether that key is in the index, and the message from a
rejected item.
your_code.py
PythonCtrl↵ to run
Hint
Sku is @dataclass(frozen=True) with two str fields. Item is @dataclass(order=True) with value declared first, tags using field(default_factory=list), and a __post_init__ that validates then sets self.value = self.quantity * self.price. index is annotated (items: list[Item], region: str) -> dict[Sku, Item] and builds Sku(item.name, region) as each key. min(items) works because the class is ordered, and the sorted values are a comprehension over sorted(items).