Skip to content

I added a field, and two different orders came out equal

  • Python · Core
  • 15 min read, about 1 h with practice
  • Concept
  • Asked often: medium
  • Reviewed 2026-11

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")
True

The two notes are different. Why are the orders equal?

  • Input: an order’s id, customer, items (a list, empty when left out) and a note (empty when left out).
  • Output: an Order that prints all its fields, like Order(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.

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

@dataclass reads the fields you list, with their types, and writes the methods for you:

from dataclasses import dataclass, field
@dataclass
class Order:
id: int
customer: str
items: list[str] = field(default_factory=list)
note: str = ""
You writePython writesSo you get
The fields, with types and defaults__init__(self, id, customer, items=…, note="")Order(1, "ana")
__repr__ with every fieldOrder(id=1, customer='ana', items=[], note='')
__eq__ comparing every field, in orderTwo 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 ordersorted() works

The types are labels. @dataclass needs them to find the fields, but doesn’t check them when you make an object.

All three make an order in O(f) for f fields. What changes is how much you write, and what becomes out of date.

Okay

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.

Better

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

Best

@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
@dataclass
class 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 week
from 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)
@dataclass
class 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.

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

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: repr(Order(1, "ana")) → "Order(id=1, customer='ana', items=[], note='')"
  2. Not run yet: Order(1, "ana") == Order(1, "ana") → True
  3. Not run yet: orders with different notes are equal? → False
  4. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
WayLines per fieldA new field reaches repr and ==Use it when
Okay: __init__ only1No repr or == at allNever for data
Better: methods by hand3 or moreOnly if you rememberYou need special rules in them
Best: @dataclass1AlwaysA class that mostly holds data
  • 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=True stops changes after creation (FrozenInstanceError) and makes objects hashable. A frozen object holding a list can still have the list changed.
  • eq=True removes __hash__: a plain dataclass can’t go in a set. Use frozen=True, not unsafe_hash=True.
  • order=True compares 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 a field(init=False).

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 make Order a dataclass: the fields are listed once, with types, and Python generates __init__, __repr__ and __eq__ from them. For the items list I use field(default_factory=list), so each order gets its own list. If orders shouldn’t change, frozen=True makes them immutable and hashable.”

Likely follow-ups

  • Dataclass or named tuple? A NamedTuple is 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.

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.

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

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: len({Point(1, 2), Point(1, 2)}) → 1
  2. Not run yet: Point(1, 2).x = 5 → raises AttributeError
  3. 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
Okay

A plain dataclass

The idea: @dataclass with x and y.

Points print and compare well.

from dataclasses import dataclass
@dataclass
class 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'.

Better

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: int

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

Best

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: int

When Best isn’t best: if points change all the time, like a moving sprite, keep them mutable and out of sets.

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

Loads about 6 MB the first time. Best on Wi-Fi.

  1. 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']
  2. Not run yet: sort_versions(['0.2.0', '0.10.0']) → ['0.2.0', '0.10.0']
  3. 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
Okay

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

Better

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.

Best

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

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: LineItem("pen", 3, 150).total → 450
  2. Not run yet: repr(LineItem("pen", 3, 150)) → "LineItem(name='pen', qty=3, unit_cents=150, total=450)"
  3. Not run yet: LineItem("pen", 0, 150) → raises ValueError
  4. 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
Okay

Three fields

The idea: A dataclass with name, qty and unit_cents; callers multiply.

The data is right.

from dataclasses import dataclass
@dataclass
class 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.

Better

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
@dataclass
class 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
@dataclass
class LineItem:
name: str
qty: int
unit_cents: int
total: int # the caller works it out and passes it in

Where it breaks: LineItem("pen", 3, 150, 999) is accepted. The total is whatever the caller says.

Best

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
@dataclass
class 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 dataclass
from dataclasses import dataclass, field
@dataclass
class 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_cents

When Best isn’t best: if qty can change later, total becomes out of date. Then make total a @property instead of a field.

A few quick questions. Ones you miss are remembered, so you can find your weak spots later.

  1. Spot the bugThis class fails as soon as Python reads it. Why?
    from dataclasses import dataclass
    
    @dataclass
    class Cart:
        owner: str
        items: list = []
  2. Predict the outputWhat does this print?
    from dataclasses import dataclass
    
    @dataclass
    class P:
        x: int
        y: int = 0
    
    print(P(1))
  3. Multiple choiceYou want Point objects as dict keys. Which line fits best?
  4. Predict the outputWhat does this print?
    from dataclasses import dataclass
    
    @dataclass(order=True)
    class V:
        major: int
        minor: int
    
    print(sorted([V(1, 10), V(1, 9), V(0, 99)])[0])

Add these to your review deck. Each card comes back a little later every time you get it right.

  • @dataclass writes __init__, __repr__ and __eq__ from the typed fields.
  • List or dict defaults: field(default_factory=list).
  • frozen=True: can’t change, hashable. order=True: < and sorted() work, field by field.
  • field(init=False) plus __post_init__ for values you work out or check.
  • Fields without defaults come first.

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