The previous lesson ended with a confession: between __init__, __repr__ and __eq__, the Book class spends half of its lines reciting its own attributes — title, price, stock — over and over again. That repetitive code (called boilerplate) is so universal that Python automated it in the standard library: the @dataclass decorator generates those methods for you from a simple list of fields. In this lesson you'll rewrite Book in a third of the lines, meet field(), frozen=True and order=True, close the decision table that 04-06 left open (namedtuple, dict, class or dataclass?) and leave the Papyrus catalog in its definitive form: a dictionary of objects.
Contents
- The boilerplate we all write the same way
@dataclass: three methods for free- Default values and
field(default_factory=...) __post_init__: validating at birthfrozen=True: immutable dataclassesorder=True: comparable and sortable- The definitive decision table: namedtuple vs dict vs class vs dataclass
- Papyrus's final catalog: a
dictof objects - Common mistakes and tips
- Exercises with solutions
The boilerplate we all write the same way
This is how Book ended up after 05-05 (just the repetitive part):
class Book:
def __init__(self, title, price, stock=0):
self.title = title
self.price = price
self.stock = stock
def __repr__(self):
return f"Book({self.title!r}, {self.price}, {self.stock})"
def __eq__(self, other):
if not isinstance(other, Book):
return NotImplemented
return (self.title, self.price, self.stock) == (other.title, other.price, other.stock)Fifteen lines in which each attribute appears five times. Adding an author field forces you to touch three methods, and forgetting one creates a silent bug (an __eq__ that ignores the new field). It's mechanical work — exactly what machines do best.
@dataclass: three methods for free
from dataclasses import dataclass
@dataclass
class Book:
"""A book in the Papyrus catalog."""
title: str
price: float
stock: int = 0That's all. The @dataclass decorator (imported from dataclasses, standard library) reads the declared fields and automatically generates __init__, __repr__ and __eq__:
odyssey = Book("The Odyssey", 12.50, 4) # generated __init__
print(odyssey) # Book(title='The Odyssey', price=12.5, stock=4) ← generated __repr__
print(odyssey == Book("The Odyssey", 12.50, 4)) # True ← generated __eq__ (field by field)
print(odyssey == Book("The Odyssey", 12.50, 3)) # False (the stock differs)Two important clarifications:
- The annotations
title: strorprice: floatare type hints, which we'll study in depth in module 8. For now, this is all you need:@dataclassrequires them to detect the fields, and it does not validate types at runtime —Book(42, "hello")doesn't complain (yet;__post_init__is coming right up). - A dataclass is a perfectly ordinary class: it can have methods, properties (05-04), inherit and be inherited. It only saves you the boilerplate.
That's why we can add the usual behavior to it:
@dataclass
class Book:
title: str
price: float
stock: int = 0
BOOK_VAT = 0.04 # no type annotation → class attribute, not a field
MEMBER_DISCOUNT = 0.05
def final_price(self, member=False):
discount = Book.MEMBER_DISCOUNT if member else 0
return round(self.price * (1 - discount) * (1 + Book.BOOK_VAT), 2)
def in_stock(self):
return self.stock > 0
print(Book("Hamlet", 9.95, 6).final_price(member=True)) # 9.83 — the canonical figureA subtle but crucial detail: BOOK_VAT = 0.04 without an annotation is an ordinary class attribute (05-01) and @dataclass ignores it as a field. With an annotation (BOOK_VAT: float = 0.04) it would become one more field of the __init__ — almost never what you want for a constant.
Default values and field(default_factory=...)
Default values work as they do in functions (stock: int = 0), and with the same trap from module 3: never a mutable object as a default value. @dataclass is even stricter than functions — it outright forbids it:
@dataclass
class Member:
name: str
code: str
history: list = [] # ValueError: mutable default <class 'list'> for field historyThe solution is field(default_factory=...): instead of one shared value, you give it a function that manufactures a fresh value for each instance:
from dataclasses import dataclass, field
@dataclass
class Member:
name: str
code: str
history: list = field(default_factory=list) # a NEW list per member
luis = Member("Luis", "LUIS-001")
marta = Member("Marta", "MARTA-002")
luis.history.append("The Odyssey")
print(marta.history) # [] — each member has their own (no 03-02 trap)field() accepts more useful settings: field(compare=False) excludes a field from the generated __eq__/ordering, and field(repr=False) hides it from the __repr__ (handy for long histories).
__post_init__: validating at birth
Since __init__ is written by the decorator, where do we put the validations from 05-04? In __post_init__, a method the dataclass calls automatically right after the generated __init__:
@dataclass
class Book:
title: str
price: float
stock: int = 0
def __post_init__(self):
self.title = self.title.strip()
if not self.title:
raise ValueError("Title cannot be empty")
if self.price < 0:
raise ValueError(f"Negative price not allowed: {self.price}")
if not isinstance(self.stock, int) or self.stock < 0:
raise ValueError(f"Invalid stock: {self.stock!r}")
Book("Ghost", -3) # ValueError: Negative price not allowed: -3
Book(" Hamlet ", 9.95) # OK, and it normalizes too: title == "Hamlet"Papyrus's business invariants — price ≥ 0, stock a non-negative integer — are protected at construction time. (Unlike the properties from 05-04, __post_init__ doesn't watch later assignments; if you need that, combine the dataclass with properties or use frozen=True, coming up next.)
frozen=True: immutable dataclasses
@dataclass(frozen=True) produces immutable objects: any later assignment raises an error, as with the tuples and namedtuples of 04-02.
@dataclass(frozen=True)
class MemberCode:
name: str
number: int
luis = MemberCode("LUIS", 1)
luis.number = 99 # FrozenInstanceError: cannot assign to field 'number'And here comes the prize 05-05 left pending: a frozen dataclass is hashable (it generates a __hash__ consistent with its __eq__), so its instances can go into sets and serve as dictionary keys (04-04):
active_members = {MemberCode("LUIS", 1), MemberCode("MARTA", 2), MemberCode("LUIS", 1)}
print(len(active_members)) # 2 — the duplicate merges, as in every setCriterion: use frozen=True for value objects that represent identifying data (codes, coordinates, money); keep mutable the ones that model state that changes (a book's stock).
order=True: comparable and sortable
@dataclass(order=True) generates __lt__, __le__, __gt__ and __ge__ comparing the fields in their declaration order, like tuples:
@dataclass(order=True)
class Reservation:
priority: int # first declared field → governs the ordering
customer: str = field(compare=False) # excluded: no tie-breaking by name
queue = [Reservation(2, "Omar"), Reservation(1, "Julia"), Reservation(2, "Pau")]
for r in sorted(queue):
print(r.priority, r.customer) # 1 Julia · 2 Omar · 2 PauThe professional trick is in plain sight: declare the field you want to sort by first and exclude with field(compare=False) the ones that shouldn't influence the order. It's the generated alternative to writing __lt__ by hand (05-05); pick one route or the other, not both.
The definitive decision table: namedtuple vs dict vs class vs dataclass
In 04-06 we closed the data structures with a decision table. OOP adds two columns and completes it:
dict |
namedtuple (04-06) |
Regular class (05-01…05) | @dataclass |
|
|---|---|---|---|---|
| Access | d["price"] |
book.price |
book.price |
book.price |
| Mutable | Yes | No | Yes | Yes (or no, with frozen) |
| Own methods | No | Limited | Yes, unrestricted | Yes, unrestricted |
Decent __repr__/__eq__ |
— | Free | By hand (05-05) | Free |
| Validation on creation | No | No | __init__/properties |
__post_init__ |
| Defined, stable fields | No (free-form keys) | Yes | Yes | Yes |
| Cost in lines | 0 | 1 | High | Low |
| Use it when... | Short-lived heterogeneous data, dynamic keys | Trivial immutable record, no behavior | Lots of logic, invariants guarded with properties, rich hierarchies | Named data + some behavior and validation |
Quick decision rule, from least to most structure: keys that change at runtime? → dict. Just an immutable record to read from? → namedtuple (or a frozen dataclass if you want validation). Named data, sensible equality, some behavior? → dataclass, the new default starting point. Does logic dominate over data (properties everywhere, an inheritance hierarchy with overrides, custom dunders)? → a full hand-written class. And the two mix freely: nothing prevents a dataclass with properties or a hierarchy whose base is a dataclass.
Papyrus's final catalog: a dict of objects
Let's close the circle opened in 04-03. The big refactor left the catalog as {title: {"price": ..., "stock": ...}}; module 5 takes it to its definitive form — a dictionary from normalized key to Book object — combining the best of both worlds: the dict's direct lookup by key and the object's intelligence.
from dataclasses import dataclass
STORE_NAME = "Papyrus"
def normalize_title(text):
"""The canonical normalization from 04-05, now the catalog key."""
return text.strip().casefold()
@dataclass
class Book:
title: str
price: float
stock: int = 0
BOOK_VAT = 0.04
MEMBER_DISCOUNT = 0.05
def __post_init__(self):
if self.price < 0:
raise ValueError(f"Negative price: {self.price}")
if self.stock < 0:
raise ValueError(f"Negative stock: {self.stock}")
def final_price(self, member=False):
discount = Book.MEMBER_DISCOUNT if member else 0
return round(self.price * (1 - discount) * (1 + Book.BOOK_VAT), 2)
def in_stock(self):
return self.stock > 0
books = [
Book("The Odyssey", 12.50, 4),
Book("Hamlet", 9.95, 6),
Book("Don Quixote", 15.90, 8),
Book("Faust", 21.00, 0),
]
# The definitive catalog: dict[str, Book] — normalized key → object
catalog = {normalize_title(book.title): book for book in books}
def find_book(catalog, wanted):
"""The papyrus_utils lookup, reduced to one line."""
return catalog.get(normalize_title(wanted))
order = find_book(catalog, " HAMLET ")
if order and order.in_stock():
print(f"{order.title}: {order.final_price(member=True):.2f} EUR for Luis")
# Hamlet: 9.83 EUR for LuisCompare with the module's starting point: find_book() no longer walks the keys normalizing them one by one — the catalog was built normalized (dictionary comprehension, 04-03) and get() (04-03) resolves the lookup, missing-case included, returning None just like the original version. And what it returns is no anonymous sub-dictionary: it's an object that knows how to compute its price and report its stock.
flowchart LR
A["Module 4<br>dict of dicts<br>+ loose functions"] --> B["05-01…05-05<br>Book class<br>written by hand"] --> C["05-06<br>Book dataclass<br>catalog: dict[str, Book]"]
Common Mistakes and Tips
- Forgetting the type annotation on a field:
title = "x"without: stris not a field — it's a class attribute and disappears from the__init__. Every dataclass field carries an annotation; every class constant does not. - A field without a default value after one that has one:
stock: int = 0followed byauthor: strgivesTypeError(same rule as function parameters in 03-02). Order them: required fields first. - A mutable default value:
history: list = []raisesValueErrorwhen the class is defined. Alwaysfield(default_factory=list)(ordict,set...). - Expecting type validation from the annotations:
Book(42, "hello")gets created without complaint. Annotations document (and module 8 tools will check them before running), but at runtime it's__post_init__that validates. frozen=Truewith a__post_init__that assigns:self.title = self.title.strip()fails withFrozenInstanceErroreven inside__post_init__. In frozen classes, validate without reassigning (or useobject.__setattr__, an advanced trick you'll rarely need).- Mixing
order=Truewith a manual__lt__: the decorator refuses to generate ordering if you already define__lt__(ValueError). One source of truth for the order, not two. - Tip: faced with a new class, start with
@dataclass. If over time it needs properties, complex inheritance or custom dunders, "promote" it to a full class: client code won't even notice, because the interface (book.price,book.final_price()) doesn't change.
Exercises
Exercise 1: Magazine as a dataclass
Rewrite the Magazine class from 05-02 as a dataclass with fields title, issue, price and stock = 0, a __post_init__ that validates that issue is a positive integer, and the final_price(member=False) method with the canonical formula. Verify that Magazine("Quimera", 482, 6.50, 10).final_price(True) returns 6.42 and that issue=0 raises ValueError.
Exercise 2: a frozen sale line
Create the dataclass SaleLine with frozen=True and order=True, and fields date (a "2026-07-13" string), title and amount — in that order, so the lines sort chronologically. Create three lines with different dates out of order, sort them with sorted() and check that a set removes a duplicate line.
Exercise 3: the complete catalog, end to end
Using the final Book dataclass and the dict[str, Book] catalog, write sell_title(catalog, wanted, member=False) that: looks up the book with find_book(); returns None if it doesn't exist; if it exists but there's no stock, returns the string "OUT OF STOCK: <title>"; and if there is stock, deducts 1 unit and returns the final price. Test with "hamlet" (member), "FAUST" and "Hopscotch".
Solutions
Solution 1:
@dataclass
class Magazine:
title: str
issue: int
price: float
stock: int = 0
BOOK_VAT = 0.04
MEMBER_DISCOUNT = 0.05
def __post_init__(self):
if not isinstance(self.issue, int) or self.issue <= 0:
raise ValueError(f"Invalid magazine issue: {self.issue!r}")
def final_price(self, member=False):
discount = Magazine.MEMBER_DISCOUNT if member else 0
return round(self.price * (1 - discount) * (1 + Magazine.BOOK_VAT), 2)
print(Magazine("Quimera", 482, 6.50, 10).final_price(True)) # 6.42
Magazine("Quimera", 0, 6.50) # ValueError: Invalid magazine issue: 0Four fields, validation and behavior in a third of the space the hand-written 05-02 version took.
Solution 2:
@dataclass(frozen=True, order=True)
class SaleLine:
date: str # first field → governs the chronological order
title: str
amount: float
lines = [
SaleLine("2026-07-13", "Hamlet", 9.83),
SaleLine("2026-07-11", "The Odyssey", 12.35),
SaleLine("2026-07-12", "Don Quixote", 16.54),
SaleLine("2026-07-11", "The Odyssey", 12.35), # duplicate
]
for line in sorted(lines):
print(line.date, line.title)
# 2026-07-11 The Odyssey · 2026-07-11 The Odyssey · 2026-07-12 Don Quixote · 2026-07-13 Hamlet
print(len(set(lines))) # 3 — frozen ⇒ hashable ⇒ the set deduplicates (04-04)yyyy-MM-dd dates sort correctly as text — and in module 6 these sale lines will be exactly what we write into a CSV.
Solution 3:
def sell_title(catalog, wanted, member=False):
book = find_book(catalog, wanted)
if book is None:
return None
if not book.in_stock():
return f"OUT OF STOCK: {book.title}"
book.stock -= 1
return book.final_price(member)
print(sell_title(catalog, "hamlet", member=True)) # 9.83
print(sell_title(catalog, "FAUST")) # OUT OF STOCK: Faust
print(sell_title(catalog, "Hopscotch")) # None
print(catalog["hamlet"].stock) # 5 — the sale really deducted stockWatch the whole module collaborate in ten lines: normalized lookup (04-05/05-06), an object that reports its own stock (05-01), a price computed by the book itself (05-01/05-02) and the catalog as a dict of objects. The three different outcomes (None, string, number) are begging for something better than "guess the return type" — the exceptions of module 7 will provide the elegant answer.
Conclusion
Module 5 delivers what it promised when module 4 closed: data and behavior no longer live apart. In 05-01, class, __init__ and self united the catalog with its functions and every book learned to compute its own member price; inheritance raised the Product → Book/Magazine → EBook family with super() distributing what's common (05-02); polymorphism let Ana's terminal charge for any item — even pedigree-free ducks like the GiftVoucher — by sending the same message (05-03); encapsulation armor-plated the business invariants with _internals and properties that reject impossible prices and stocks (05-04); the magic methods taught your objects Python's native language — print, ==, sorted, len, in, + — and settled the ugly print (05-05); and dataclasses eliminated the boilerplate, validated at birth with __post_init__ and completed the decision table that 04-06 left open. The end result is Papyrus's definitive catalog: dict[str, Book], normalized key to intelligent object. But this building has a flaw that Ana discovers every night when she shuts down the computer: everything — the catalog, the members, the sale lines — lives in memory and vanishes when the program closes. Tomorrow, catalog is reborn from scratch, with "Faust" once again missing the copies that arrived. Module 6 solves exactly that: files — text, CSV, JSON — so that Papyrus's state survives between runs. Your objects already know how to think; now they'll learn to remember.
Python Programming Course
Module 1: Introduction to Python
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
