Dataclasses and type hints
What `@dataclass` writes for you, why `field(default_factory=...)` is needed, what `frozen=True` gives, type hints on functions, and what it means that hints do nothing while the program runs but everything for a type checker.
How do you want to learn?
The problem we are solving
Written out in full, the last chapter's Book class comes to this:
class Book:
def __init__(self, title, author, pages):
self.title = title
self.author = author
self.pages = pages
def __repr__(self):
return f"Book({self.title!r}, {self.author!r}, {self.pages!r})"
def __eq__(self, other):
return (self.title, self.author, self.pages) == (other.title, other.author, other.pages)
a = Book("one", "rafi", 300)
print(a)
print(a == Book("one", "rafi", 300))Book('one', 'rafi', 300)
TrueThirteen lines, and not one real decision in them. Each field's name is written again and again — as a parameter of __init__, in the self. line, in __repr__ and in __eq__.
Adding a fourth field means adding it in all those places. Forget one and nothing raises — __repr__ or __eq__ is just quietly incomplete.
from dataclasses import dataclass
@dataclass
class Book:
title: str
author: str
pages: int
a = Book("one", "rafi", 300)
print(a)
print(a == Book("one", "rafi", 300))Book(title='one', author='rafi', pages=300)
TrueSeven lines, and each field named once.
By the end of this chapter you can
- Write a
@dataclass, and say what it generates - Use
field(default_factory=...), and say why it is needed - Say what
frozen=Truegives you - Write type hints on functions —
list[str],dict[str, float],int | None - Say that hints do nothing while the program runs, but a lot for a type checker
- Send a dataclass to JSON with
asdict
What you need first: Classes.
What @dataclass writes for you
In exchange for those seven lines you get:
- An
__init__whose parameters follow the fields, in order - A
__repr__showing every field with its name - An
__eq__calling two objects equal when all their fields match
Three of the last chapter's problems — <object at 0x...>, a == b giving False, and writing the same names again and again — are solved together.
Methods are written exactly as before:
from dataclasses import dataclass
@dataclass
class OrderLine:
name: str
price: float
quantity: int
def total(self) -> float:
return round(self.price * self.quantity * 1.15, 2)
line = OrderLine("pen", 15.0, 3)
print(line)
print(line.total())OrderLine(name='pen', price=15.0, quantity=3)
51.75Defaults work too. The rule is the same as in chapter 20: fields with defaults come last.
from dataclasses import dataclass
@dataclass
class Book:
title: str
pages: int = 1
print(Book("one"))
print(Book("one", 300))Book(title='one', pages=1)
Book(title='one', pages=300)Hints do nothing while the program runs
This has to be clear early, because the name suggests something else.
from dataclasses import dataclass
@dataclass
class Book:
title: str
pages: int
b = Book("one", "three hundred")
print(b)
print(b.pages + 1)Book(title='one', pages='three hundred')
TypeError: can only concatenate str (not "int") to strpages: int is written, and still a string went straight in. Python does not check hints. It only stores them; using them is somebody else's job.
The error arrived much later, when something tried to calculate with that value. In between it was printed — in a real program it might also have been logged, or written to a file.
This is where a type checker helps. mypy is a separate program that reads the code without running it. Save the code above as main.py and run mypy main.py:
main.py:10: error: Argument 2 to "Book" has incompatible type "str"; expected "int" [arg-type]The line number, which argument, what was given and what was expected — and the program was never run.
The whole idea in two sentences: hints do nothing while the program runs; for a type checker they are everything. Writing a hint is a promise to the type checker and to the reader, not to the interpreter.
The stored hints can be looked at:
def shout(text: str) -> str:
return text.upper()
print(shout("pen"))
print(shout.__annotations__)PEN
{'text': <class 'str'>, 'return': <class 'str'>}The mutable default, a third time
In chapter 21 it was a function's default argument. In chapter 26 it was a class-level attribute. Now it is a dataclass field — and this time Python stops it itself:
from dataclasses import dataclass
@dataclass
class Shelf:
books: list = []ValueError: mutable default <class 'list'> for field books is not allowed: use default_factoryNotice that no object was created at all — the error came while the class was being defined. Python has seen this mistake so often that here it simply refuses.
The right way is to give a factory — not the built thing, but what to build:
from dataclasses import dataclass, field
@dataclass
class Shelf:
name: str
books: list[str] = field(default_factory=list)
a = Shelf("a")
b = Shelf("b")
a.books.append("one")
print(a)
print(b)Shelf(name='a', books=['one'])
Shelf(name='b', books=[])field(default_factory=list) means "call list() once for each new object". It does the same job as chapter 21's if basket is None: basket = [], in one line and with no room to get it wrong.
frozen=True
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
p = Point(1, 2)
print(p)
print({p, Point(1, 2)})
p.x = 5Point(x=1, y=2)
{Point(x=1, y=2)}
dataclasses.FrozenInstanceError: cannot assign to field 'x'Two things happened here together, and they are connected.
The object can no longer be changed. p.x = 5 stopped with an error — just like a tuple.
And the object can now go in a set. Remember the last chapter's final problem: writing __eq__ takes the hash away. Here two "equal" points became one in the set, with no error at all.
Without frozen:
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
print({Point(1, 2)})TypeError: unhashable type: 'Point'The reason comes from chapter 16: what can change is not safe to hash. If it changed after being put in a set, it could never be found again.
frozen=True says "this will not change", so Python is willing to supply the hash.
The rule is simple: freeze any object that is not supposed to change after it is made. You get a guarantee, and with it the ability to use the object in a set or as a dictionary key.
Hints on functions
def totals(rows: list[tuple[str, float]]) -> dict[str, float]:
result: dict[str, float] = {}
for name, amount in rows:
result[name] = result.get(name, 0.0) + amount
return resultlist[str], dict[str, float], tuple[str, float] — this way you can also say what is inside, and that is what makes hints useful.
Plain list says only "a list". list[tuple[str, float]] says what each pass of the loop will get.
Give it the wrong thing — say line 9 of main.py is totals("pen") — and mypy says:
main.py:9: error: Argument 1 to "totals" has incompatible type "str"; expected "list[tuple[str, float]]" [arg-type]For "there may or may not be one", use |:
def find(names: list[str], wanted: str) -> str | None:
for name in names:
if name == wanted:
return name
return None
print(find(["pen"], "pen"))
print(find(["pen"], "bag"))pen
NoneThis is where hints help the most. Use the result without checking for None — say line 8 becomes print(find(["pen"], "pen").upper()) — and mypy says:
main.py:8: error: Item "None" of "str | None" has no attribute "upper" [union-attr]That is really AttributeError: 'NoneType' object has no attribute 'upper' — one of the errors seen most often in practice. But here it is caught before the program runs, and without ever meeting the input that would produce None.
From dataclass to JSON
import json
from dataclasses import dataclass, asdict
@dataclass
class Book:
title: str
pages: int
print(asdict(Book("one", 300)))
print(json.dumps(asdict(Book("one", 300))))
print(json.dumps(Book("one", 300))){'title': 'one', 'pages': 300}
{"title": "one", "pages": 300}
TypeError: Object of type Book is not JSON serializableRemember the list from chapter 25: objects of your own do not go straight into JSON. asdict is the bridge. It also turns dataclasses nested inside into dictionaries.
A complete example
The last chapter's order, rebuilt with dataclasses and hints.
"""The order of chapter twenty-six, rebuilt with dataclasses and hints."""
import json
from dataclasses import dataclass, field, asdict
TAX_RATE = 0.15
@dataclass(frozen=True)
class OrderLine:
"""Frozen: a line never changes once made, so it can live in a set."""
name: str
price: float
quantity: int
def __post_init__(self) -> None:
if self.quantity < 1:
raise ValueError(f"quantity must be at least 1: {self.quantity}")
def total(self) -> float:
return round(self.price * self.quantity * (1 + TAX_RATE), 2)
@dataclass
class Order:
"""Not frozen: lines are added over time."""
customer: str
lines: list[OrderLine] = field(default_factory=list)
def add(self, name: str, price: float, quantity: int) -> "Order":
self.lines.append(OrderLine(name, price, quantity))
return self
def total(self) -> float:
return round(sum(line.total() for line in self.lines), 2)
def largest(self) -> OrderLine | None:
if not self.lines:
return None
return max(self.lines, key=lambda line: line.total())
def main() -> None:
order = Order("rafi")
order.add("pen", 15.0, 3)
order.add("bag", 850.0, 1)
order.add("ink", 120.0, 2)
for line in order.lines:
print(f"{line.name:<6} {line.total():>9.2f}")
print(f"{'total':<6} {order.total():>9.2f}")
print()
print(order.largest())
print(json.dumps(asdict(order)))
print(len({OrderLine("pen", 15.0, 3), OrderLine("pen", 15.0, 3)}))
try:
order.add("clip", 5.0, 0)
except ValueError as err:
print("rejected:", err)
if __name__ == "__main__":
main()pen 51.75
bag 977.50
ink 276.00
total 1305.25
OrderLine(name='bag', price=850.0, quantity=1)
{"customer": "rafi", "lines": [{"name": "pen", "price": 15.0, "quantity": 3}, {"name": "bag", "price": 850.0, "quantity": 1}, {"name": "ink", "price": 120.0, "quantity": 2}]}
1
rejected: quantity must be at least 1: 0Five things to notice.
__post_init__ is where validation goes. @dataclass writes __init__ itself, so there is no place there for a check. __post_init__ fills that gap — it runs just after the fields are set.
So the last chapter's guarantee still holds: having an OrderLine means its quantity is at least one.
One is frozen and the other is not — on purpose. A line is not supposed to change once made. But an order keeps getting new lines. The question is the same each time: does this thing change after it is created?
The 1 proves that freezing worked. Two identical OrderLines became one in the set.
Running the same line with the last chapter's OrderLine would print 2. There would be no error: that class had no __eq__, so it kept the default hash, but two separate objects were never equal — the set simply kept both.
list[OrderLine] is not decoration. It is how the type checker knows that line.total() is valid. If you wrote line.totl(), it would catch it — without running the code.
"Order" is in quotes. When the name Order is written in the signature of add, the class is not finished being defined — the name does not exist yet. Inside quotes, Python does not look the name up at that moment.
In Python 3.12, the version this course uses, there are two ways to use a class's own name in a hint inside that class: put the name in quotes, or write from __future__ import annotations at the very top of the file.
When it breaks
ValueError: mutable default ... use default_factory = [] or = {} was written. Use field(default_factory=list).
TypeError: non-default argument follows default argument A field with a default is placed before a required one. The rule from chapter 20 again: required fields first.
dataclasses.FrozenInstanceError Something was assigned on a frozen=True object. When a change is needed, dataclasses.replace(obj, x=5) gives a new object.
TypeError: unhashable type A dataclass without frozen=True cannot go in a set.
A wrong type went in and nothing happened As expected — hints do nothing while the program runs. Run mypy.
mypy is not looking at my file Give it the path — mypy main.py. And if the first run shows a flood of messages, start with mypy --ignore-missing-imports.
TypeError: Object of type X is not JSON serializable Send asdict(obj).
NameError for my own class's name inside itself Put the name in quotes: -> "Order". Or write from __future__ import annotations at the top of the file.
Step 4 of 6 — Predict
Check your understanding
A seven-line dataclass. What do the two lines print?
from dataclasses import dataclass
@dataclass
class Book:
title: str
pages: int
print(Book("one", 300))
print(Book("one", 300) == Book("one", 300))- A<__main__.Book object at 0x...> False
- BBook(title='one', pages=300) True
- CBook(title='one', pages=300) False
- DBook('one', 300) True
No object is made, only the class is written. What happens?
from dataclasses import dataclass
@dataclass
class Shelf:
books: list = []- ANothing; the problem appears when the first object is made
- BA `ValueError` — while the class is being defined
- CA `TypeError`
- DNothing; every instance will share one list
frozen=True is set. What happens?
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
p = Point(1, 2)
print({p, Point(1, 2)})
p.x = 5- AA set of one prints, then a `FrozenInstanceError`
- BA set of two prints, then a `FrozenInstanceError`
- C`TypeError: unhashable type` — at the set
- DA set of one prints, then `p.x` becomes `5`
Answering needs an account
Sign in to check your answers
The questions are above, and working them out in your head is the part that matters. Sign in to see the answers, the explanations and the three-level hints.
Your turn
Rewrite chapter 26's Book and Shelf as dataclasses.
Book is frozen=True with title: str, author: str, pages: int, and a __post_init__ raising ValueError when pages < 1. Shelf has name: str and books: list[Book] = field(default_factory=list), plus add, total_pages() -> int, longest() -> Book | None and by_author(name: str) -> list[Book].
Then pip install mypy, run mypy library.py, and fix things until it reports nothing.
Then six experiments:
- Pass a string for
pages—Book("one", "rafi", "300"). Does the program run? And what doesmypysay? - Replace
books'sfield(default_factory=list)with= []. When does the error arrive — when an object is made, or before that? - Make two identical
Books and put them in a set. How many are there? Then removefrozen=Trueand try again. - Use
longest()'s result directly —shelf.longest().title— on an empty shelf. What does the program say, and what hadmypysaid beforehand? asdict(shelf), write it withjson.dumps, and read it back withjson.loads. Is what comes back aShelf?- Read a misspelled attribute —
book.pagse. Doesmypycatch it?
Those last two show this chapter's limit and its power together. What comes back from JSON is a dictionary, not a dataclass — the translation runs one way, and you have to write the way back yourself. But the gap that chapter 26 could not close — a wrong name caught only when the code runs — the type checker closes before the code runs at all.
Step 6 of 6
Stretch — the chapter quiz
Ten questions from easy to hard. The last ones are difficult on purpose.
Sign in to take the quiz