I added a field, and two different orders came out equal
See the problem
Section titled “See the problem”Your shop’s Order class has an id, a customer and a list of items. You wrote __repr__ and __eq__ by hand, so orders print well and compare by value. Last week you added a note field. Today the code that removes duplicate orders threw one away:
>>> Order(1, "ana", note="gift wrap") == Order(1, "ana", note="leave at door")TrueThe two notes are different. Why are the orders equal?
Define the task
Section titled “Define the task”- Input: an order’s id, customer, items (a list, empty when left out) and a note (empty when left out).
- Output: an
Orderthat prints all its fields, likeOrder(id=1, customer='ana', items=[], note=''), and is equal to another only when every field is equal. - Rules: each order gets its own items list. Adding a field later must not mean editing three methods.
- The hard part: methods written by hand don’t know when a field is added.
Pause and think
Section titled “Pause and think”Hints open one at a time:
Hint 1 of 3
Nothing changed in __eq__ or __repr__ when note was added to __init__.
Hint 2 of 3
If Python wrote the methods from a list of the fields, a new field would be in all of them.
Hint 3 of 3
@dataclass reads the typed fields in the class body. A list default needs field(default_factory=list).
Understand the idea
Section titled “Understand the idea”@dataclass reads the fields you list, with their types, and writes the methods for you:
from dataclasses import dataclass, field
@dataclassclass Order: id: int customer: str items: list[str] = field(default_factory=list) note: str = ""| You write | Python writes | So you get |
|---|---|---|
| The fields, with types and defaults | __init__(self, id, customer, items=…, note="") | Order(1, "ana") |
__repr__ with every field | Order(id=1, customer='ana', items=[], note='') | |
__eq__ comparing every field, in order | Two orders are equal only if all fields are | |
@dataclass(frozen=True) | Setting a field raises an error; __hash__ | Objects for sets and dict keys |
@dataclass(order=True) | <, <=, >, >=, comparing fields in order | sorted() works |
The types are labels. @dataclass needs them to find the fields, but doesn’t check them when you make an object.
Climb the ladder
Section titled “Climb the ladder”All three make an order in O(f) for f fields. What changes is how much you write, and what becomes out of date.
Only __init__
The idea: Set each field in __init__, and nothing else.
The None check gives each order its own list. Holding the data works.
class Order: def __init__( self, id: int, customer: str, items: list[str] | None = None, note: str = "" ) -> None: self.id = id self.customer = customer self.items = items if items is not None else [] self.note = note- Time
O(f)- Space
O(f)- Why
- One attribute per field.
Where it breaks: an order prints as <Order object at 0x…>, and two identical orders aren’t equal, because == compares identity.
__repr__ and __eq__ by hand
The idea: Write __repr__ and __eq__ that list the fields.
The waste in Okay: the object can’t show or compare its own data.
Highlighted lines are new in this rung.
class Order: def __init__( self, id: int, customer: str, items: list[str] | None = None, note: str = "" ) -> None: self.id = id self.customer = customer self.items = items if items is not None else [] self.note = note # added last week
def __repr__(self) -> str: return f"Order(id={self.id!r}, customer={self.customer!r}, items={self.items!r})"
def __eq__(self, other: object) -> bool: if not isinstance(other, Order): return NotImplemented return (self.id, self.customer, self.items) == (other.id, other.customer, other.items)- Time
O(f)- Space
O(f)- Why
- Each method goes through the fields.
What changed from the previous rung
class Order: def __init__( self, id: int, customer: str, items: list[str] | None = None, note: str = "" ) -> None: self.id = id self.customer = customer self.items = items if items is not None else [] self.note = note self.note = note # added last week
def __repr__(self) -> str: return f"Order(id={self.id!r}, customer={self.customer!r}, items={self.items!r})"
def __eq__(self, other: object) -> bool: if not isinstance(other, Order): return NotImplemented return (self.id, self.customer, self.items) == (other.id, other.customer, other.items)Where it breaks: each field is now listed in three places. note reached __init__, but not __repr__ or __eq__, so two orders with different notes are “equal”.
@dataclass
The idea: List the fields with types; field(default_factory=list) makes a new list per order.
The waste in Better: you keep three lists of the same fields in step by hand.
Each field is listed once. items: list[str] = [] would be refused: ValueError: mutable default <class 'list'> for field items is not allowed. default_factory calls list() for each new order.
Highlighted lines are new in this rung.
from dataclasses import dataclass, field
@dataclassclass Order: id: int customer: str items: list[str] = field(default_factory=list) # a new list for each order note: str = ""- Time
O(f)- Space
O(f)- Why
- The same methods, written by Python.
What changed from the previous rung
class Order: def __init__( self, id: int, customer: str, items: list[str] | None = None, note: str = "" ) -> None: self.id = id self.customer = customer self.items = items if items is not None else [] self.note = note # added last weekfrom dataclasses import dataclass, field
def __repr__(self) -> str: return f"Order(id={self.id!r}, customer={self.customer!r}, items={self.items!r})"
def __eq__(self, other: object) -> bool: if not isinstance(other, Order): return NotImplemented return (self.id, self.customer, self.items) == (other.id, other.customer, other.items)@dataclassclass Order: id: int customer: str items: list[str] = field(default_factory=list) # a new list for each order note: str = ""When Best isn’t best: a class with real behaviour and rules, like a bank account, often wants its own __init__. For data from outside, like JSON from an API, Pydantic models check the types too.
Write it yourself
Section titled “Write it yourself”Turn the starter into a dataclass. The tests make orders, then print and compare them.
An order that prints and compares every field
Ctrl + Enter runs- Not run yet:
repr(Order(1, "ana"))→"Order(id=1, customer='ana', items=[], note='')" - Not run yet:
Order(1, "ana") == Order(1, "ana")→True - Not run yet:
orders with different notes are equal?→False - Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Compare
Section titled “Compare”| Way | Lines per field | A new field reaches repr and == | Use it when |
|---|---|---|---|
Okay: __init__ only | 1 | No repr or == at all | Never for data |
| Better: methods by hand | 3 or more | Only if you remember | You need special rules in them |
Best: @dataclass | 1 | Always | A class that mostly holds data |
Common mistakes and edge cases
Section titled “Common mistakes and edge cases”- Defaults go last: a field without a default can’t come after one with a default:
TypeError: non-default argument 'b' follows default argument. frozen=Truestops changes after creation (FrozenInstanceError) and makes objects hashable. A frozen object holding a list can still have the list changed.eq=Trueremoves__hash__: a plain dataclass can’t go in a set. Usefrozen=True, notunsafe_hash=True.order=Truecompares fields in the order they’re written, like tuples. Put the most important field first.__post_init__runs right after the generated__init__. Check values there, or fill afield(init=False).
Say it in the interview
Section titled “Say it in the interview”Questions to ask first
- Will these objects change after they’re made?
- Will they go in a set, be dict keys, or be sorted?
A 60-second walkthrough
“The bug is that the hand-written
__eq__didn’t get the new field. Each field was listed in three places, and one fell out of step. I’d makeOrdera dataclass: the fields are listed once, with types, and Python generates__init__,__repr__and__eq__from them. For the items list I usefield(default_factory=list), so each order gets its own list. If orders shouldn’t change,frozen=Truemakes them immutable and hashable.”
Likely follow-ups
- Dataclass or named tuple? A
NamedTupleis a tuple: it can’t change, unpacks, and compares with plain tuples. A dataclass is a normal class with more options. - Dataclass or Pydantic? Dataclasses don’t check types. Pydantic checks and converts input, which suits data from outside.
- What is
slots=True? It adds__slots__, so each object uses less memory. The__slots__lesson explains it.
Practice
Section titled “Practice”Three problems, from easy to hard. Each has hints, tests you can run here, and its own Okay, Better and Best answers. Try before you open them.
1. A point for a set (easy)
Section titled “1. A point for a set (easy)”Write a dataclass Point(x, y) that can go in a set, so {Point(1, 2), Point(1, 2)} holds one point. Changing a point after it is made must raise an error.
Hint 1 of 3
A plain dataclass has __eq__, so Python removes __hash__: it can’t go in a set.
Hint 2 of 3
unsafe_hash=True adds a hash, but the point can still change after it’s in the set.
Hint 3 of 3
frozen=True adds a hash and stops changes.
A point for a set
Ctrl + Enter runs- Not run yet:
len({Point(1, 2), Point(1, 2)})→1 - Not run yet:
Point(1, 2).x = 5→ raisesAttributeError - Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
A plain dataclass
The idea: @dataclass with x and y.
Points print and compare well.
from dataclasses import dataclass
@dataclassclass Point: x: int y: int- Time
O(1)- Space
O(1)- Why
- Two fields.
Where it breaks: {Point(1, 2)} raises TypeError: unhashable type: 'Point'.
unsafe_hash=True
The idea: Ask the dataclass for a __hash__ anyway.
The waste in Okay: a value-like object that can’t go in a set.
Highlighted lines are new in this rung.
from dataclasses import dataclass
@dataclass(unsafe_hash=True)class Point: x: int y: int- Time
O(1)- Space
O(1)- Why
- Two fields.
What changed from the previous rung
from dataclasses import dataclass
@dataclass@dataclass(unsafe_hash=True)class Point: x: int y: intWhere it breaks: put p in a set, then set p.x = 5. Its hash changes, and p in seen is now False, though it’s right there.
frozen=True
The idea: A frozen dataclass can’t change, so its hash can’t either.
The waste in Better: a hash that can change under the set.
p.x = 5 raises FrozenInstanceError, a kind of AttributeError. To “change” a point, make a new one: dataclasses.replace(p, x=5).
Highlighted lines are new in this rung.
from dataclasses import dataclass
@dataclass(frozen=True)class Point: x: int y: int- Time
O(1)- Space
O(1)- Why
- Two fields.
What changed from the previous rung
from dataclasses import dataclass
@dataclass(unsafe_hash=True)@dataclass(frozen=True)class Point: x: int y: intWhen Best isn’t best: if points change all the time, like a moving sprite, keep them mutable and out of sets.
2. Sort version numbers (medium)
Section titled “2. Sort version numbers (medium)”Write sort_versions(texts). Sort version strings like "1.10.0" from oldest to newest. 1.9.2 comes before 1.9.10, and both come before 1.10.0.
Hint 1 of 3
Sorting text compares character by character, so "1.10" comes before "1.9".
Hint 2 of 3
Splitting on "." still gives text: "10" < "9" as text.
Hint 3 of 3
A dataclass with order=True compares its int fields in order, like a tuple.
Sort version numbers
Ctrl + Enter runs- Not run yet:
sort_versions(['1.10.0', '1.9.2', '2.0.0', '1.9.10'])→['1.9.2', '1.9.10', '1.10.0', '2.0.0'] - Not run yet:
sort_versions(['0.2.0', '0.10.0'])→['0.2.0', '0.10.0'] - Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Sort the text
The idea: sorted(texts).
2.0.0 comes after the 1.x versions.
def sort_versions(texts: list[str]) -> list[str]: return sorted(texts)- Time
O(n log n)- Space
O(n)- Why
- One sort.
Where it breaks: text compares a character at a time, so "1.10.0" comes before "1.9.2".
Split on dots
The idea: Sort by text.split(“.”).
The waste in Okay: it compares the dots too.
Highlighted lines are new in this rung.
def sort_versions(texts: list[str]) -> list[str]: return sorted(texts, key=lambda t: t.split("."))- Time
O(n log n)- Space
O(n)- Why
- One sort, with a list key per version.
What changed from the previous rung
def sort_versions(texts: list[str]) -> list[str]: return sorted(texts) return sorted(texts, key=lambda t: t.split("."))Where it breaks: the parts are still text, and "10" < "9". The order doesn’t change.
A Version dataclass
The idea: Parse each text into a frozen, ordered Version with int fields, sort those, and turn them back into text.
The waste in Better: it never turns the parts into numbers.
order=True compares major, then minor, then patch, as numbers.
Highlighted lines are new in this rung.
from dataclasses import dataclass
@dataclass(frozen=True, order=True)class Version: major: int minor: int patch: int
@classmethod def parse(cls, text: str) -> "Version": major, minor, patch = (int(part) for part in text.split(".")) return cls(major, minor, patch)
def __str__(self) -> str: return f"{self.major}.{self.minor}.{self.patch}"
def sort_versions(texts: list[str]) -> list[str]: return [str(v) for v in sorted(Version.parse(t) for t in texts)]- Time
O(n log n)- Space
O(n)- Why
- One parse per version, then one sort.
What changed from the previous rung
from dataclasses import dataclass
@dataclass(frozen=True, order=True)class Version: major: int minor: int patch: int
@classmethod def parse(cls, text: str) -> "Version": major, minor, patch = (int(part) for part in text.split(".")) return cls(major, minor, patch)
def __str__(self) -> str: return f"{self.major}.{self.minor}.{self.patch}"
def sort_versions(texts: list[str]) -> list[str]: return sorted(texts, key=lambda t: t.split(".")) return [str(v) for v in sorted(Version.parse(t) for t in texts)]When Best isn’t best: real versions have parts like 2.0.0rc1. Use packaging.version.Version, which follows Python’s own rules.
3. A line item that works out its total (hard)
Section titled “3. A line item that works out its total (hard)”Write a dataclass LineItem(name, qty, unit_cents) with a total field that is always qty * unit_cents. Callers can’t pass total. A qty below 1 raises ValueError("qty must be at least 1, not 0"). It prints as LineItem(name='pen', qty=3, unit_cents=150, total=450).
Hint 1 of 3
A total the caller passes in can be wrong.
Hint 2 of 3
field(init=False) keeps a field out of __init__, but still in __repr__ and ==.
Hint 3 of 3
__post_init__ runs right after __init__: check qty there and set total.
A line item that works out its total
Ctrl + Enter runs- Not run yet:
LineItem("pen", 3, 150).total→450 - Not run yet:
repr(LineItem("pen", 3, 150))→"LineItem(name='pen', qty=3, unit_cents=150, total=450)" - Not run yet:
LineItem("pen", 0, 150)→ raisesValueError - Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Three fields
The idea: A dataclass with name, qty and unit_cents; callers multiply.
The data is right.
from dataclasses import dataclass
@dataclassclass LineItem: name: str qty: int unit_cents: int- Time
O(1)- Space
O(1)- Why
- Three fields.
Where it breaks: there’s no total, so every caller works it out. And qty=0 is accepted.
Pass the total in
The idea: Add a total field that callers fill in.
The waste in Okay: every caller does the same sum.
Highlighted lines are new in this rung.
from dataclasses import dataclass
@dataclassclass LineItem: name: str qty: int unit_cents: int total: int # the caller works it out and passes it in- Time
O(1)- Space
O(1)- Why
- Four fields.
What changed from the previous rung
from dataclasses import dataclass
@dataclassclass LineItem: name: str qty: int unit_cents: int total: int # the caller works it out and passes it inWhere it breaks: LineItem("pen", 3, 150, 999) is accepted. The total is whatever the caller says.
field(init=False) and __post_init__
The idea: Keep total out of __init__, and set it in __post_init__ after checking qty.
The waste in Better: it asks for a value it could work out.
Highlighted lines are new in this rung.
from dataclasses import dataclass, field
@dataclassclass LineItem: name: str qty: int unit_cents: int total: int = field(init=False) # not an argument: worked out below
def __post_init__(self) -> None: if self.qty <= 0: raise ValueError(f"qty must be at least 1, not {self.qty}") self.total = self.qty * self.unit_cents- Time
O(1)- Space
O(1)- Why
- One check and one sum.
What changed from the previous rung
from dataclasses import dataclassfrom dataclasses import dataclass, field
@dataclassclass LineItem: name: str qty: int unit_cents: int total: int # the caller works it out and passes it in total: int = field(init=False) # not an argument: worked out below
def __post_init__(self) -> None: if self.qty <= 0: raise ValueError(f"qty must be at least 1, not {self.qty}") self.total = self.qty * self.unit_centsWhen Best isn’t best: if qty can change later, total becomes out of date. Then make total a @property instead of a field.
Check yourself
Section titled “Check yourself”A few quick questions. Ones you miss are remembered, so you can find your weak spots later.
Flashcards
Section titled “Flashcards”Add these to your review deck. Each card comes back a little later every time you get it right.
Cheat sheet
Section titled “Cheat sheet”@dataclasswrites__init__,__repr__and__eq__from the typed fields.- List or dict defaults:
field(default_factory=list). frozen=True: can’t change, hashable.order=True:<andsorted()work, field by field.field(init=False)plus__post_init__for values you work out or check.- Fields without defaults come first.
Go deeper
Section titled “Go deeper”dataclassesin the Python docs.- PEP 557, which added dataclasses, and why.
- Next: Iterators and generators.
Can you do these now?
If you’re not sure of any of these, scroll back to that part before moving on.
Status: Not started