Session 25 - Dunder Methods

Session 25 of the Python Fundamentals series, covered in depth. It covers the special double-underscore methods that hook Python's own syntax onto your classes: __init__, which you already know; __str__ against __repr__, which serve print and the shell against containers; __eq__ for value equality; __len__ for len(); __lt__ for sorting; and __add__ for +. The traps are defining __str__ but expecting a list of your objects to use it, when containers print each item with __repr__, and comparing objects with == when there is no __eq__, since Python falls back to identity and two objects with equal values come out False. Every snippet, output line, and error message was executed and copied verbatim from CPython 3.12.

Subject: Python Fundamentals · 97 slides · code lesson

Open the interactive version of this deck · Homework for this lesson

What this lesson covers

The lesson, slide by slide

1. Dunder Methods

Title

Python Fundamentals - Session 25

The double-underscore methods that hook Python's syntax onto your own objects

2. What you will be able to do

Objectives

You already write classes with __init__. That name has a shape - two underscores on each side - and it is one of a family Python calls automatically. By the end you can:

  1. Explain what a dunder method is and read the default <...object at 0x...> repr.
  2. Write __str__ and __repr__, and say which one print, the shell, and lists use.
  3. Add __eq__ so == compares values, not identity.
  1. Add __len__ so len() works on your object.
  2. Add __lt__ so sorted(), min(), and max() work.
  3. Add __add__ so + builds a new object - and avoid the __str__-in-a-list and ==-without-__eq__ traps.

3. What survived from Session 24 - Methods & Encapsulation?

Warm-up

Discussion prompt

Before we open Session 25 - Dunder Methods: without looking back, what was the main idea of Session 24 - Methods & Encapsulation, and what could you do by the end of it that you could not do before?

Hint: One sentence for the idea, one for the skill. If the second one is blank, that is the part to revisit.

Answer:

Session 24 of the Python Fundamentals series, in depth. Methods that read state versus methods that change it; one method calling another through self; encapsulation - keeping internals private-by-convention with a leading underscore and exposing behavior through methods; protecting an invariant (a balance that must never go negative) by raising ValueError; and the difference between class attributes (shared by every instance) and instance attributes (one per object).

4. Hooks into Syntax

Section

Part 1

5. A dunder method hooks Python syntax

Concept

When you write len(x), x == y, or print(x), Python quietly looks for a specially-named method on x and calls it. Define that method and the syntax works on your objects.

dunder method — A method whose name is wrapped in double underscores, like __init__ or __len__. 'Dunder' = 'double underscore'. Python calls them for you when the matching syntax runs - you rarely call them by name.

6. Break it if you can: A dunder method hooks Python syntax

Counterexample

Discussion prompt

When you write len(x), x == y, or print(x), Python quietly looks for a specially-named method on x and calls it. Define that method and the syntax works on your objects.

That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.

Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.

7. You already know one: __init__

Concept

__init__ is a dunder. You never call obj.__init__() yourself - Python runs it when you write ClassName(...). Every dunder works the same way: a name Python watches for.

This session adds five more of the most useful ones, each tied to a piece of syntax you already use on built-in types.

8. By analogy: You already know one: __init__

Analogy

Discussion prompt

Explain You already know one: __init__ by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.

Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.

Answer:

__init__ is a dunder. You never call obj.__init__() yourself - Python runs it when you write ClassName(...). Every dunder works the same way: a name Python watches for.

9. Restore the missing line: __init__ builds the object's data

Fill the middle

Fill in the blanks

From __init__ builds the object's data — one line has had its right-hand side removed. Put it back.

class Point:
def __init__(self, x, y):
self.x = x
self.y = y

p = Point(1, 2)
print(p.x, p.y)

Why: p is what everything below it consumes, so the wrong expression here fails later and somewhere else. You wrote Point(1, 2), not p.__init__(...).

10. __init__ builds the object's data

Worked example

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
print(p.x, p.y)

Point(1, 2) runs __init__ for you

Why: You wrote Point(1, 2), not p.__init__(...). Python called the dunder and passed 1 and 2.

The attributes are now stored on p

Why: Verified by execution: prints 1 2. The rest of this deck adds more dunders to this same Point class.

expressionvalue
p.x1
p.y2

11. Fill in: value for __init__ builds the object's data

Comparison

Comparison matrix

From __init__ builds the object's data: refill the value column from what you know. The rest of the table is as it appeared.

expressionvalue
p.x1
p.y2

12. Dunders are Python's connectors

Intuition

Think of Python's syntax - +, ==, len(), print(), sorting - as sockets. A dunder method is the plug on your class that fits one of those sockets.

No plug, and the socket either does something generic (print an address, compare identity) or refuses (raises a TypeError). Add the plug and your object behaves like a built-in.

13. Teach it back: Dunders are Python's connectors

Explain it

Discussion prompt

Explain Dunders are Python's connectors to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.

Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.

Answer:

Think of Python's syntax - +, ==, len(), print(), sorting - as sockets. A dunder method is the plug on your class that fits one of those sockets.

14. The Default repr

Section

Part 2

15. Every object already has a text form

Concept

Print an object with no dunders and Python still shows something: a fallback built into every object. It is technically correct but useless to a human.

That fallback is the default repr - the class name and the object's memory address.

16. What print shows with no dunders

Worked example

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
print(p)

Point has no __str__ or __repr__ yet

Why: So print falls back to the default: the class location and a memory address.

Read the (verified) output

Why: Verified by execution: prints <__main__.Point object at 0x...>. The 0x... hex differs every run - it is where the object lives in memory, not its data.

you printyou get
print(p)<__main__.Point object at 0x...>

17. Where the cost goes: What print shows with no dunders

Cost model

Annotate

In What print shows with no dunders, before reading the notes: mark where the time actually goes. Which line dominates?

  • So print falls back to the default: the class location and a memory address.
  • Verified by execution: prints <__main__.Point object at 0x...>. The 0x... hex differs every run - it is where the object lives in memory, not its data.

18. That address tells you nothing useful

Concept

<__main__.Point object at 0x...> says it is a Point and where it sits in memory - but not that x is 1 and y is 2. For debugging, that is exactly what you want to see.

The fix is to define a dunder that returns a useful string. There are two of them, and the difference matters.

19. __str__ and __repr__

Section

Part 3

20. __str__ is the friendly form

Concept

__str__ returns the string shown to a human. print(x) and str(x) both call it. Make it readable - it is what an end user or a log line sees.

__str__ — Returns the 'nice' string for an object. Called by print(x) and str(x). If it is missing, Python uses __repr__ instead.

21. Take the definitions apart: dunder method vs __str__

Definition probe

Sort into buckets

Every line below is part of the definition of dunder method or of __str__ — one or the other, never both. Put each where it belongs.

dunder method
A method whose name is wrapped in double underscores, like __init__ or __len__.; 'Dunder' = 'double underscore'.; Python calls them for you when the matching syntax runs - you rarely call them by name.
__str__
Returns the 'nice' string for an object.; Called by print(x) and str(x).; If it is missing, Python uses __repr__ instead.
b1
A method whose name is wrapped in double underscores, like __init__ or __len__. 'Dunder' = 'double underscore'. Python calls them for you when the matching syntax runs - you rarely call them by name.
b2
Returns the 'nice' string for an object. Called by print(x) and str(x). If it is missing, Python uses __repr__ instead.

22. Add __str__ and print improves

Worked example

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def __str__(self):
        return f"Point({self.x}, {self.y})"

p = Point(1, 2)
print(p)

print(p) now calls __str__

Why: Instead of the memory address, print asks the object for its __str__ string.

Read the (verified) output

Why: Verified by execution: prints Point(1, 2). The f-string builds it from the object's own x and y.

you printyou get
print(p) (no __str__)<__main__.Point object at 0x...>
print(p) (with __str__)Point(1, 2)

23. What each one costs: Add __str__ and print improves

Trade off

Comparison matrix

From Add __str__ and print improves: every row here is a choice with a cost. Fill the you get column, then say which row you would actually pick and what you give up for it.

you printyou get
print(p) (no __str__)<__main__.Point object at 0x...>
print(p) (with __str__)Point(1, 2)

24. __repr__ is the unambiguous form

Concept

__repr__ returns a string aimed at a programmer - ideally one that shows exactly what the object is. repr(x) calls it, and so does the interactive shell when you type x and hit Enter.

__repr__ — Returns the 'developer' string for an object. Called by repr(x), by the interactive shell (echoing a value), and by containers (lists, dicts) when they print their items.

25. Where does each piece belong: Session 25 - Dunder Methods

Sorting

Sort into buckets

These are the pieces of Session 25 - Dunder Methods, out of order. Put each one back under the part of the lesson it belongs to.

Hooks into Syntax
A dunder method hooks Python syntax; You already know one: __init__; __init__ builds the object's data
The Default repr
Every object already has a text form; What print shows with no dunders; That address tells you nothing useful
__str__ and __repr__
__str__ is the friendly form; Add __str__ and print improves; __repr__ is the unambiguous form
s1
Hooks into Syntax is where Session 25 - Dunder Methods puts A dunder method hooks Python syntax, You already know one: __init__, __init__ builds the object's data. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.
s2
The Default repr is where Session 25 - Dunder Methods puts Every object already has a text form, What print shows with no dunders, That address tells you nothing useful. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.
s3
__str__ and __repr__ is where Session 25 - Dunder Methods puts __str__ is the friendly form, Add __str__ and print improves, __repr__ is the unambiguous form. Knowing which part of the lesson a problem belongs to is most of knowing which method to reach for.

26. Predict the next row: Add __repr__: print, repr, and the shell

Pattern

Predict first

The table runs: print(p) | __repr__ (no __str__) | Point(1, 2) · repr(p) | __repr__ | Point(1, 2)

In Add __repr__: print, repr, and the shell, given the rows so far: what is the next one — the row where call is p in the shell?

Correct: p in the shell | __repr__ | Point(1, 2)

callusesoutput
print(p)__repr__ (no __str__)Point(1, 2)
repr(p)__repr__Point(1, 2)
p in the shell__repr__Point(1, 2)

Why: The relationship between the columns, not the individual numbers, is what generates the next row. print prefers __str__, but if there is no __str__ it falls back to __repr__ - so both lines use the same string here.

27. Add __repr__: print, repr, and the shell

Worked example

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def __repr__(self):
        return f"Point({self.x}, {self.y})"

p = Point(1, 2)
print(p)
print(repr(p))

With only __repr__, print uses it too

Why: print prefers __str__, but if there is no __str__ it falls back to __repr__ - so both lines use the same string here.

Read the (verified) output

Why: Verified by execution: prints Point(1, 2) on both lines. In the shell, just typing p would also echo Point(1, 2).

callusesoutput
print(p)__repr__ (no __str__)Point(1, 2)
repr(p)__repr__Point(1, 2)
p in the shell__repr__Point(1, 2)

28. Inspect it line by line: Add __repr__: print, repr, and the shell

Error analysis

Annotate

Walk the callouts on Add __repr__: print, repr, and the shell. Each one is a place this is easy to get subtly wrong.

  • print prefers __str__, but if there is no __str__ it falls back to __repr__ - so both lines use the same string here.
  • Verified by execution: prints Point(1, 2) on both lines. In the shell, just typing p would also echo Point(1, 2).

29. str for users, repr for you

Intuition

__str__ is the label on the box - short and friendly. __repr__ is the packing slip - precise enough that another programmer (or future you) knows exactly what is inside.

A common habit: always write __repr__, and add __str__ only when the friendly form should differ from the precise one.

30. repr is the fallback for str

Concept

If a class defines __repr__ but not __str__, then str(x) and print(x) use __repr__. The reverse is not true: defining only __str__ does not give you a good __repr__.

That is why __repr__ is the one to reach for first - it covers more situations.

31. Finish it with less help: Only __repr__ covers str and lists

Faded example

Fill in the blanks

Only __repr__ covers str and lists, with the scaffolding fading: two lines are gone now — fill both.

class Dog:
def __init__(self, name):
self.name = name
def __repr__(self):
return f"Dog(Dog("Rex"))"

d = ___
print(d)
print(str(d))
print([d])

Why: Reproducing these unaided, rather than reading them, is what tells you the method has transferred. With no __str__, all three routes fall back to __repr__.

32. Only __repr__ covers str and lists

Worked example

class Dog:
    def __init__(self, name):
        self.name = name
    def __repr__(self):
        return f"Dog({self.name!r})"

d = Dog("Rex")
print(d)
print(str(d))
print([d])

__repr__ serves print, str, and the list

Why: With no __str__, all three routes fall back to __repr__. The !r in the f-string inserts repr('Rex'), so the name shows with quotes.

Read the (verified) output

Why: Verified by execution: prints Dog('Rex') three times. One well-written __repr__ covered every case.

calloutput
print(d)Dog('Rex')
print(str(d))Dog('Rex')
print([d])[Dog('Rex')]

33. Watch it run: Only __repr__ covers str and lists

Pattern

Step through it

Step through Only __repr__ covers str and lists one row at a time. What is driving the change, and what would the row after the last one be?

  1. Step 1: call is print(d)
  2. Step 2: call is print(str(d))
  3. Step 3: call is print([d])

34. When the two forms should differ

Concept

Define both when the human form and the precise form are genuinely different. A temperature might print as 20 degrees but repr as Temp(20) - the first reads nicely, the second shows the exact object.

Each route then picks its own: print and str() take __str__; repr(), the shell, and containers take __repr__.

35. Both defined: each route picks its own

Worked example

class Temp:
    def __init__(self, c):
        self.c = c
    def __str__(self):
        return f"{self.c} degrees"
    def __repr__(self):
        return f"Temp({self.c})"

t = Temp(20)
print(t)
print(repr(t))
print([t])

print takes __str__; repr and the list take __repr__

Why: print(t) uses the friendly __str__; repr(t) and the list both use __repr__ - each syntax routes to the dunder it is tied to.

Read the (verified) output

Why: Verified by execution: prints 20 degrees, then Temp(20), then [Temp(20)]. Same object, two purpose-built strings.

calldunder usedoutput
print(t)__str__20 degrees
repr(t)__repr__Temp(20)
[t]__repr__[Temp(20)]

36. Watch it run: Both defined: each route picks its own

Pattern

Step through it

Step through Both defined: each route picks its own one row at a time. What is driving the change, and what would the row after the last one be?

  1. Step 1: call is print(t)
  2. Step 2: call is repr(t)
  3. Step 3: call is [t]

37. Something is wrong here: __str__ but printing a list of objects

Anomaly

Predict first

A student writes this, and it looks reasonable:

You define __str__, then print a list of objects and expect the friendly strings.

It is wrong. Say what breaks — and say it before you turn the page.

Correct: A container prints each item with __repr__, not __str__.

Define __repr__ (the form containers use). Then lists show it - and print/str fall back to it too.

Why: A container prints each item with __repr__, not __str__. Point has no __repr__, so you get the default addresses back - exactly what you were trying to avoid.

38. Trap: __str__ but printing a list of objects

Trap

The trap

You define __str__, then print a list of objects and expect the friendly strings.

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def __str__(self):
        return f"Point({self.x}, {self.y})"

points = [Point(1, 2), Point(3, 4)]
print(points)

The list ignores __str__

Why: A container prints each item with __repr__, not __str__. Point has no __repr__, so you get the default addresses back - exactly what you were trying to avoid.

you printyou get
print(points)[<__main__.Point object at 0x...>, <__main__.Point object at 0x...>]

The fix

Define __repr__ (the form containers use). Then lists show it - and print/str fall back to it too.

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def __repr__(self):
        return f"Point({self.x}, {self.y})"

points = [Point(1, 2), Point(3, 4)]
print(points)

The list now shows each __repr__

Why: Verified by execution: prints [Point(1, 2), Point(3, 4)]. Rule of thumb: for anything you will see inside a list or dict, you want __repr__.

you printyou get
print(points)[Point(1, 2), Point(3, 4)]

39. Break it on purpose: __str__ but printing a list of objects

Break the constraint

Discussion prompt

The rule this trap just fixed:

Verified by execution: prints [Point(1, 2), Point(3, 4)]. Rule of thumb: for anything you will see inside a list or dict, you want __repr__.

Now break it on purpose. Build a case that violates it and follow the consequences until something visibly fails. Where does the failure first show up — and would you have noticed it if you had not been looking?

Hint: The dangerous rules are the ones whose violation still produces an answer. If yours fails loudly, try to find one that fails quietly.

Answer:

A container prints each item with __repr__, not __str__. Point has no __repr__, so you get the default addresses back - exactly what you were trying to avoid.

40. __eq__: Value Equality

Section

Part 4

41. == defaults to identity

Concept

Without an __eq__, a == b asks a narrow question: are a and b the same object in memory? Two separately-built objects are never the same object, even with identical data.

So Point(1, 2) == Point(1, 2) is False by default - which usually surprises people.

42. Identity vs equality

Concept

identity vs equality — Identity (is): the very same object. Equality (==): objects that count as equal by their values. Default == uses identity; __eq__ lets you define value equality instead.

For numbers and strings, == already compares values - because their classes define __eq__. Your own class starts with only the identity check.

43. Term to definition: Session 25 - Dunder Methods

Matching

Match the pairs

Match each term to the definition this lesson gave it — not the one you would guess from the word.

  • t1. __str__
  • t2. __repr__
  • t3. identity vs equality
  • d1. Returns the 'nice' string for an object. Called by print(x) and str(x). If it is missing, Python uses __repr__ instead.
  • d2. Returns the 'developer' string for an object. Called by repr(x), by the interactive shell (echoing a value), and by containers (lists, dicts) when they print their items.
  • d3. Identity (is): the very same object. Equality (==): objects that count as equal by their values. Default == uses identity; __eq__ lets you define value equality instead.

Why: These are the working definitions of __str__, __repr__, identity vs equality as Session 25 - Dunder Methods uses them. Pairing them correctly is the test of whether you could state each one with the slide switched off.

44. Equal-looking Points, == is False

Worked example

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

a = Point(1, 2)
b = Point(1, 2)
print(a == b)
print(a == a)

a and b hold the same numbers but are different objects

Why: a == b uses the default identity check, and a is not the same object as b.

Read the (verified) output

Why: Verified by execution: prints False then True. a == a is True only because a is literally the same object as itself.

comparisonsame object?result
a == bnoFalse
a == ayesTrue

45. Draw the shape of it: Equal-looking Points, == is False

Blank canvas

Draw it

Draw what Equal-looking Points, == is False just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.

46. Something is wrong here: comparing objects without __eq__

Anomaly

Predict first

A student writes this, and it looks reasonable:

You built two objects with the same data and check whether they match.

It is wrong. Say what breaks — and say it before you turn the page.

Correct: got and wanted carry identical values but are distinct objects, so == is False and the else branch runs - even though a human would call them equal.

Define __eq__ so == compares the values you care about.

Why: got and wanted carry identical values but are distinct objects, so == is False and the else branch runs - even though a human would call them equal.

47. Trap: comparing objects without __eq__

Trap

The trap

You built two objects with the same data and check whether they match.

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

wanted = Point(1, 2)
got = Point(1, 2)
if got == wanted:
    print("match")
else:
    print("no match")

== falls back to identity and says no

Why: got and wanted carry identical values but are distinct objects, so == is False and the else branch runs - even though a human would call them equal.

expressionchecksprints
got == wantedsame object? (no)no match

The fix

Define __eq__ so == compares the values you care about.

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

wanted = Point(1, 2)
got = Point(1, 2)
if got == wanted:
    print("match")
else:
    print("no match")

Now == compares x and y

Why: Verified by execution: prints match. __eq__ receives the other object and returns True when the values line up.

expressionchecksprints
got == wantedx and y equal? (yes)match

48. Which of these survive contact with Session 25 - Dunder Methods?

Two truths and a lie

Sort into buckets

Some of these hold up and some are the exact mistakes this lesson is built to prevent. Sort them.

Holds up
__init__ is a dunder. You never call obj.__init__() yourself - Python runs it when you write ClassName(...). Every dunder works the same way: a name Python watches for.; Think of Python's syntax - +, ==, len(), print(), sorting - as sockets. A dunder method is the plug on your class that fits one of those sockets.; Print an object with no dunders and Python still shows something: a fallback built into every object. It is technically correct but useless to a human.
Breaks
You define __str__, then print a list of objects and expect the friendly strings.; You built two objects with the same data and check whether they match.
sound
These are stated as this lesson states them — each one survives the edge cases Session 25 - Dunder Methods puts it through.
flawed
Each of these is lifted from a trap in this deck: reasonable-sounding, and wrong in a way that only shows up once you rely on it.

49. __eq__ powers != and in too

Worked example

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

seen = [Point(1, 2), Point(3, 4)]
print(Point(1, 2) in seen)
print(Point(9, 9) in seen)
print(Point(1, 2) != Point(3, 4))

in scans the list using ==

Why: Membership tests compare each item with ==, which now runs your __eq__. So a value-equal Point counts as present.

!= is derived from ==

Why: Verified by execution: prints True, False, True. Defining __eq__ gives you != for free - Python negates the result.

expressionresult
Point(1, 2) in seenTrue
Point(9, 9) in seenFalse
Point(1, 2) != Point(3, 4)True

50. Fill in: result for __eq__ powers != and in too

Comparison

Comparison matrix

From __eq__ powers != and in too: refill the result column from what you know. The rest of the table is as it appeared.

expressionresult
Point(1, 2) in seenTrue
Point(9, 9) in seenFalse
Point(1, 2) != Point(3, 4)True

51. __len__: len()

Section

Part 5

52. len() calls __len__

Concept

len(x) does not measure anything on its own - it calls x.__len__() and hands back whatever integer that returns. Lists and strings work with len() because their classes define it.

Give your class a __len__ and it decides what its own 'length' means.

53. Restore the missing line: len() before __len__: TypeError

Fill the middle

Fill in the blanks

From len() before __len__: TypeError — one line has had its right-hand side removed. Put it back.

class Team:
def __init__(self, members):
self.members = members

t = Team(["Ana", "Ben", "Cid"])
print(len(t))

Why: self.members is what everything below it consumes, so the wrong expression here fails later and somewhere else. len() looks for the dunder, does not find it, and refuses - it will not guess.

54. len() before __len__: TypeError

Worked example

class Team:
    def __init__(self, members):
        self.members = members

t = Team(["Ana", "Ben", "Cid"])
print(len(t))

Team has no __len__

Why: len() looks for the dunder, does not find it, and refuses - it will not guess.

Read the (verified) error

Why: Verified by execution: raises TypeError: object of type 'Team' has no len(). The error even names the missing capability.

you writeresult
len(t)TypeError: object of type 'Team' has no len()

55. Add __len__ and len() works

Worked example

class Team:
    def __init__(self, members):
        self.members = members
    def __len__(self):
        return len(self.members)

t = Team(["Ana", "Ben", "Cid"])
print(len(t))

__len__ returns the count you choose

Why: Here 'length of a Team' means how many members it has, so __len__ returns len(self.members).

Read the (verified) output

Why: Verified by execution: prints 3. len(t) now calls __len__, which reports 3.

members__len__ returnslen(t)
['Ana', 'Ben', 'Cid']33

56. __lt__: Sorting

Section

Part 6

57. Sorting needs 'less than'

Concept

sorted() orders items by repeatedly asking 'is this one less than that one?'. That question is <, which calls __lt__. No __lt__, and Python has no way to order your objects.

Define __lt__ and you pick the key that ranks them.

58. Sorting before __lt__: TypeError

Worked example

class Player:
    def __init__(self, name, score):
        self.name = name
        self.score = score

players = [Player("Ana", 30), Player("Ben", 10)]
print(sorted(players))

sorted() tries to compare two Players

Why: To order them it needs Player < Player, but Player defines no __lt__.

Read the (verified) error

Why: Verified by execution: raises TypeError: '<' not supported between instances of 'Player' and 'Player'. Python is telling you the missing operator.

you writeresult
sorted(players)TypeError: '<' not supported between instances of 'Player' and 'Player'

59. Add __lt__ and sorted() ranks them

Worked example

class Player:
    def __init__(self, name, score):
        self.name = name
        self.score = score
    def __repr__(self):
        return f"{self.name}({self.score})"
    def __lt__(self, other):
        return self.score < other.score

players = [Player("Ana", 30), Player("Ben", 10), Player("Cid", 20)]
print(sorted(players))

__lt__ ranks by score

Why: self < other now means 'my score is lower'. sorted() uses that to order lowest-score first.

Read the (verified) output

Why: Verified by execution: prints [Ben(10), Cid(20), Ana(30)]. The __repr__ makes the list readable; __lt__ decides the order.

sorted positionplayerscore
1stBen10
2ndCid20
3rdAna30

60. Watch it run: Add __lt__ and sorted() ranks them

Pattern

Step through it

Step through Add __lt__ and sorted() ranks them one row at a time. What is driving the change, and what would the row after the last one be?

  1. Step 1: sorted position is 1st
  2. Step 2: sorted position is 2nd
  3. Step 3: sorted position is 3rd

61. min() and max() ride along

Concept

min(), max(), and .sort() all lean on the same < comparison. Define __lt__ once and every one of them works on your objects.

That is the payoff of hooking a dunder: one method, many built-in tools.

62. One __lt__, three tools

Worked example

class Player:
    def __init__(self, name, score):
        self.name = name
        self.score = score
    def __repr__(self):
        return f"{self.name}({self.score})"
    def __lt__(self, other):
        return self.score < other.score

players = [Player("Ana", 30), Player("Ben", 10), Player("Cid", 20)]
print(min(players))
print(max(players))

min and max reuse __lt__

Why: min finds the object nothing is less than; max finds the greatest. Both use the same score comparison.

Read the (verified) output

Why: Verified by execution: prints Ben(10) then Ana(30). No extra code - defining __lt__ was enough.

callpicksoutput
min(players)lowest scoreBen(10)
max(players)highest scoreAna(30)

63. __add__: the + Operator

Section

Part 7

64. + calls __add__

Concept

a + b is shorthand for a.__add__(b). Numbers add, strings join, lists concatenate - all because each class defines __add__ to mean something sensible.

Your class defines what + should build. Usually that is a brand-new object, leaving the originals unchanged.

65. + before __add__: TypeError

Worked example

class Money:
    def __init__(self, cents):
        self.cents = cents

a = Money(150)
b = Money(75)
print(a + b)

Money has no __add__

Why: + looks for the dunder on the left operand, does not find it, and gives up.

Read the (verified) error

Why: Verified by execution: raises TypeError: unsupported operand type(s) for +: 'Money' and 'Money'. Python will not invent addition for you.

you writeresult
a + bTypeError: unsupported operand type(s) for +: 'Money' and 'Money'

66. Draw the shape of it: + before __add__: TypeError

Blank canvas

Draw it

Draw what + before __add__: TypeError just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.

67. Finish it with less help: Add __add__: + builds a new object

Faded example

Fill in the blanks

Add __add__: + builds a new object, with the scaffolding fading: two lines are gone now — fill both.

class Money:
def __init__(self, cents):
self.cents = cents
def __repr__(self):
return f"Money(Money(75))"
def __add__(self, other):
return Money(self.cents + other.cents)

a = Money(150)
b = ___
print(a + b)

Why: Reproducing these unaided, rather than reading them, is what tells you the method has transferred. It reads both cents totals, adds them, and builds a new Money.

68. Add __add__: + builds a new object

Worked example

class Money:
    def __init__(self, cents):
        self.cents = cents
    def __repr__(self):
        return f"Money({self.cents})"
    def __add__(self, other):
        return Money(self.cents + other.cents)

a = Money(150)
b = Money(75)
print(a + b)

__add__ returns a fresh Money

Why: It reads both cents totals, adds them, and builds a new Money. a and b are untouched.

Read the (verified) output

Why: Verified by execution: prints Money(225). 150 + 75 cents, wrapped back into a Money object you can keep using.

expressioncentsresult
a150Money(150)
b75Money(75)
a + b150 + 75Money(225)

69. Inspect it line by line: Add __add__: + builds a new object

Error analysis

Annotate

Walk the callouts on Add __add__: + builds a new object. Each one is a place this is easy to get subtly wrong.

  • It reads both cents totals, adds them, and builds a new Money. a and b are untouched.
  • Verified by execution: prints Money(225). 150 + 75 cents, wrapped back into a Money object you can keep using.

70. Restore the missing line: Return a new object, and + chains

Fill the middle

Fill in the blanks

From Return a new object, and + chains — one line has had its right-hand side removed. Put it back.

class Vec:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Vec(Vec(3, 4), ___)"
def __add__(self, other):
return Vec(self.x + other.x, self.y + other.y)

a = Vec(1, 2)
b = ___
c = Vec(10, 10)
print(a + b + c)

Why: b is what everything below it consumes, so the wrong expression here fails later and somewhere else. a + b builds Vec(4, 6); that result has __add__ too, so + c works on it.

71. Return a new object, and + chains

Worked example

class Vec:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def __repr__(self):
        return f"Vec({self.x}, {self.y})"
    def __add__(self, other):
        return Vec(self.x + other.x, self.y + other.y)

a = Vec(1, 2)
b = Vec(3, 4)
c = Vec(10, 10)
print(a + b + c)

Because + returns a Vec, you can add again

Why: a + b builds Vec(4, 6); that result has __add__ too, so + c works on it. Returning a new object of the same type is what makes chaining possible.

Read the (verified) output

Why: Verified by execution: prints Vec(14, 16). Left to right: (a + b) is Vec(4, 6), then + c gives Vec(14, 16).

stepxy
a + b46
(a + b) + c1416

72. What each one costs: Return a new object, and + chains

Trade off

Comparison matrix

From Return a new object, and + chains: every row here is a choice with a cost. Fill the x column, then say which row you would actually pick and what you give up for it.

stepxy
a + b46
(a + b) + c1416

73. Operators are method calls in disguise

Intuition

+, ==, <, and len(...) look like built-in machinery, but each is just a readable spelling of a method call. a + b is a.__add__(b).

Once you see that, adding operator support to your own class stops feeling like magic - you are only filling in the method Python was already going to call.

74. Teach it back: Operators are method calls in disguise

Explain it

Discussion prompt

Explain Operators are method calls in disguise to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.

Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.

Answer:

+, ==, <, and len(...) look like built-in machinery, but each is just a readable spelling of a method call. a + b is a.__add__(b).

75. Putting It Together

Section

Part 8

76. One class, many dunders

Concept

A class can define as many dunders as it needs. Each one wires up a different piece of syntax, and they do not interfere with each other.

Add only the ones your object should support - a Money wants __add__, __eq__, and a __repr__; it probably has no use for __len__.

77. By analogy: One class, many dunders

Analogy

Discussion prompt

Explain One class, many dunders by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.

Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.

Answer:

A class can define as many dunders as it needs. Each one wires up a different piece of syntax, and they do not interfere with each other.

78. Predict the next row: A Money class that feels built-in

Pattern

Predict first

The table runs: Money(150) + Money(75) | __add__ | Money(225) · print(wallet) | __repr__ | Money(225)

In A Money class that feels built-in, given the rows so far: what is the next one — the row where expression is wallet == Money(225)?

Correct: wallet == Money(225) | __eq__ | True

expressiondunder usedresult
Money(150) + Money(75)__add__Money(225)
print(wallet)__repr__Money(225)
wallet == Money(225)__eq__True

Why: The relationship between the columns, not the individual numbers, is what generates the next row. The + builds Money(225); print asks __repr__ for its string; == runs __eq__ to compare cents.

79. A Money class that feels built-in

Worked example

class Money:
    def __init__(self, cents):
        self.cents = cents
    def __repr__(self):
        return f"Money({self.cents})"
    def __eq__(self, other):
        return self.cents == other.cents
    def __add__(self, other):
        return Money(self.cents + other.cents)

wallet = Money(150) + Money(75)
print(wallet)
print(wallet == Money(225))

__add__, then __repr__, then __eq__ all fire

Why: The + builds Money(225); print asks __repr__ for its string; == runs __eq__ to compare cents. Three dunders, one expression each.

Read the (verified) output

Why: Verified by execution: prints Money(225) then True. The object now adds, prints, and compares like a number.

expressiondunder usedresult
Money(150) + Money(75)__add__Money(225)
print(wallet)__repr__Money(225)
wallet == Money(225)__eq__True

80. Fill in: dunder used for A Money class that feels built-in

Comparison

Comparison matrix

From A Money class that feels built-in: refill the dunder used column from what you know. The rest of the table is as it appeared.

expressiondunder usedresult
Money(150) + Money(75)__add__Money(225)
print(wallet)__repr__Money(225)
wallet == Money(225)__eq__True

81. Dunders make objects first-class

Intuition

Built-in types feel effortless because they answer to +, ==, len(), and print already. Dunders are how you give the same fluency to a class you wrote.

You are not learning new syntax - you are teaching your objects to speak the syntax you already know.

82. Break it if you can: Dunders make objects first-class

Counterexample

Discussion prompt

Built-in types feel effortless because they answer to +, ==, len(), and print already. Dunders are how you give the same fluency to a class you wrote.

That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.

Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.

Answer:

You are not learning new syntax - you are teaching your objects to speak the syntax you already know.

83. Patterns & Checks

Section

Part 9

84. Which dunder for which syntax

Pattern

Want print / str / f-strings to read well? define __repr__ (add __str__ only if the friendly form differs)

Why: __repr__ is the fallback for str and the form containers use, so it covers the most cases.

Want == to compare values? define __eq__(self, other)

Why: Otherwise == checks identity and two equal-valued objects come out False. You also get != for free.

Want len(x)? define __len__ returning an int

Why: len() calls __len__; without it you get TypeError: object of type '...' has no len().

Want sorted / min / max? define __lt__(self, other)

Why: They all rank items with <; without __lt__ you get TypeError: '<' not supported between instances.

Want a + b? define __add__(self, other) returning a new object

Why: + calls __add__; returning a fresh same-type object leaves the originals alone and lets + chain.

85. Writing a solid __repr__

Pattern

1. Return a string, built from the object's own attributes

Why: The whole point is to show the data - self.x, self.cents - not a memory address.

2. Prefer an f-string that looks like how you'd build the object

Why: f"Point({self.x}, {self.y})" reads like Point(1, 2). Use !r on strings so quotes show.

3. Reach for __repr__ before __str__

Why: print, str(), the shell, and lists all fall back to __repr__ - one method covers them.

86. Where this shows up: Session 25 - Dunder Methods

Real world

Discussion prompt

Outside this lesson: where does Session 25 - Dunder Methods actually turn up? Name one concrete situation — a job, a piece of software someone ships, a decision somebody has to make — and say which part of Writing a solid __repr__ is doing the work in it.

Hint: Vague is the failure mode here. "Engineering" is not a situation; "deciding whether this build is fast enough to ship" is.

Answer:

Session 25 of the Python Fundamentals series, in depth. Special double-underscore methods that hook Python's own syntax onto your classes: __init__ (already known), __str__ vs __repr__ (print and the shell vs containers), __eq__ for value equality, __len__ for len(), __lt__ for sorting, and __add__ for +.

87. Check: the default repr

Check

No __str__ or __repr__ is defined.

class Cat:
    def __init__(self, name):
        self.name = name

c = Cat("Mimi")
print(c)
has __repr__?prints
no?

Check your understanding

What does print(c) show?

  • A. Cat("Mimi")
  • B. Mimi
  • C. <__main__.Cat object at 0x...> (correct)
  • D. TypeError

Answer: C

Why: With no __str__ or __repr__, print falls back to the default repr: the class location and a memory address. Verified by execution.

Why A tempts people
That would require a __repr__ that returns it; none is defined, so you get the default address form.
Why B tempts people
Printing just the name would need a __str__ or __repr__ returning self.name; there is none.
Why D tempts people
print always works - every object has the default repr, so there is no error.

88. Check: __str__ inside a list

Check

__str__ is defined, but you print a list of the objects.

class P:
    def __init__(self, n):
        self.n = n
    def __str__(self):
        return f"P{self.n}"

print([P(1), P(2)])
container usesprints
__repr__?

Check your understanding

What does this print?

  • A. [P1, P2]
  • B. [<__main__.P object at 0x...>, <__main__.P object at 0x...>] (correct)
  • C. P1 P2
  • D. TypeError

Answer: B

Why: A list prints each item with __repr__, not __str__. P defines only __str__, so the items fall back to the default address repr. Verified by execution.

Why A tempts people
That is what you'd get if __repr__ returned P1/P2 - but only __str__ is defined, and containers ignore __str__.
Why C tempts people
print([...]) shows a list with brackets and commas, not space-separated bare values.
Why D tempts people
There is no error; the items just use the default repr.

89. Check: == without __eq__

Check

Two objects with the same data, no __eq__ defined.

class Box:
    def __init__(self, v):
        self.v = v

print(Box(5) == Box(5))
== checksresult
identity?

Check your understanding

What does this print?

  • A. True
  • B. False (correct)
  • C. 5
  • D. TypeError

Answer: B

Why: With no __eq__, == compares identity. The two Box objects are built separately, so they are different objects and == is False. Verified by execution.

Why A tempts people
True would require an __eq__ comparing v. Without it, == asks 'same object?' - and these are two objects.
Why C tempts people
== returns a bool (True/False), never the stored value 5.
Why D tempts people
Comparing objects with == never errors; the default just falls back to identity.

90. Check: value equality and in

Check

Now __eq__ compares the value.

class Box:
    def __init__(self, v):
        self.v = v
    def __eq__(self, other):
        return self.v == other.v

seen = [Box(1), Box(2)]
print(Box(2) in seen)
in usesresult
==?

Check your understanding

What does this print?

  • A. True (correct)
  • B. False
  • C. 2
  • D. TypeError

Answer: A

Why: in compares each item with ==, which now runs __eq__. Box(2) is value-equal to the second item, so it is found. Verified by execution.

Why B tempts people
False would be the answer without __eq__ (identity). With __eq__ comparing v, the value-equal Box matches.
Why C tempts people
in returns a bool, not the value inside the box.
Why D tempts people
No error - __eq__ is defined, so membership testing works fine.

91. Check: len() on your object

Check

__len__ returns the length of the wrapped list.

class Bag:
    def __init__(self, items):
        self.items = items
    def __len__(self):
        return len(self.items)

b = Bag(["a", "b", "c", "d"])
print(len(b))
itemsreturns
4 items?

Check your understanding

What does this print?

  • A. 4 (correct)
  • B. 1
  • C. TypeError
  • D. None

Answer: A

Why: len(b) calls __len__, which returns len(self.items) - the list has 4 items, so it returns 4. Verified by execution.

Why B tempts people
1 would treat the Bag as a single thing; but __len__ delegates to the inner list's length, which is 4.
Why C tempts people
TypeError is what you'd get with NO __len__. Here it is defined, so len() works.
Why D tempts people
__len__ returns an int (4), and len() passes it straight back - never None.

92. Check: sorting with __lt__

Check

__lt__ compares by score.

class P:
    def __init__(self, s):
        self.s = s
    def __repr__(self):
        return str(self.s)
    def __lt__(self, other):
        return self.s < other.s

print(sorted([P(3), P(1), P(2)]))
sorts byresult
__lt__ (s)?

Check your understanding

What does this print?

  • A. [1, 2, 3] (correct)
  • B. [3, 1, 2]
  • C. [3, 2, 1]
  • D. TypeError

Answer: A

Why: sorted() orders lowest-first using __lt__ (self.s < other.s), and __repr__ prints each score, giving [1, 2, 3]. Verified by execution.

Why B tempts people
That is the original order; sorted() returns a new ordered list, it does not leave them unsorted.
Why C tempts people
That is descending order; sorted() defaults to ascending (lowest s first).
Why D tempts people
TypeError happens only without __lt__. Here it is defined, so sorting works.

93. Check: + with __add__

Check

__add__ combines the counts into a new Jar.

class Jar:
    def __init__(self, n):
        self.n = n
    def __repr__(self):
        return f"Jar({self.n})"
    def __add__(self, other):
        return Jar(self.n + other.n)

print(Jar(3) + Jar(4))
+ callsresult
__add__?

Check your understanding

What does this print?

  • A. Jar(7) (correct)
  • B. 7
  • C. Jar(3)Jar(4)
  • D. TypeError

Answer: A

Why: __add__ adds the two n values (3 + 4) and returns a new Jar(7); __repr__ then prints it as Jar(7). Verified by execution.

Why B tempts people
7 would be the raw number, but __add__ wraps the sum back into a Jar, and __repr__ shows Jar(7).
Why C tempts people
+ does not concatenate the two reprs; it runs __add__, which builds one new Jar from the sum.
Why D tempts people
TypeError is the no-__add__ case. Here __add__ is defined, so + works.

94. Check: repr as the str fallback

Check

Only __repr__ is defined - no __str__.

class Tag:
    def __init__(self, name):
        self.name = name
    def __repr__(self):
        return f"Tag({self.name!r})"

t = Tag("sale")
print(str(t))
str() falls back toprints
__repr__?

Check your understanding

What does print(str(t)) show?

  • A. Tag('sale') (correct)
  • B. sale
  • C. <__main__.Tag object at 0x...>
  • D. TypeError

Answer: A

Why: With no __str__, str() falls back to __repr__, which returns Tag('sale') - the !r adds the quotes around 'sale'. Verified by execution.

Why B tempts people
Bare sale (no quotes, no Tag) would need a __str__ returning self.name; none is defined, so repr is used.
Why C tempts people
The default address form only appears when NO __repr__ exists; here __repr__ is defined.
Why D tempts people
str() never errors here - it just uses __repr__ as its fallback.

95. Check: both __str__ and __repr__

Check

Both are defined, and you print the object directly.

class Coin:
    def __init__(self, v):
        self.v = v
    def __str__(self):
        return f"{self.v}c"
    def __repr__(self):
        return f"Coin({self.v})"

print(Coin(25))
print usesprints
__str__?

Check your understanding

What does print(Coin(25)) show?

  • A. 25c (correct)
  • B. Coin(25)
  • C. <__main__.Coin object at 0x...>
  • D. 25

Answer: A

Why: print prefers __str__ when it exists, so it uses the friendly form and shows 25c. __repr__ would be used by repr(), the shell, or a list. Verified by execution.

Why B tempts people
Coin(25) is the __repr__ form; you'd see it from repr(Coin(25)) or inside a list, but print uses __str__ first.
Why C tempts people
The default address only appears when neither dunder is defined; here both are.
Why D tempts people
Bare 25 would need a __str__ returning str(self.v); this __str__ adds the 'c', giving 25c.

96. Connect it up: Session 25 - Dunder Methods

Connect it up

Draw it

One page, no notation unless you need it: draw how these connect — Hooks into Syntax · The Default repr · __str__ and __repr__ · __eq__: Value Equality · __len__: len() · __lt__: Sorting. Put an arrow wherever one of them is what makes another possible, and label the arrow with why.

97. What you can do now

Recap

A dunder method is a double-underscore name Python calls for you when matching syntax runs. Define the plug and your object fits the socket - print, ==, len(), sorting, +.

SyntaxDunder it callsWithout it
print(x) / str(x)__str__ (else __repr__)<...object at 0x...>
x in the shell / [x]__repr__<...object at 0x...>
a == b__eq__identity: False for equal values
len(x)__len__TypeError: ... has no len()
sorted / min / max__lt__TypeError: '<' not supported
a + b__add__TypeError: unsupported operand type(s)

Reach for __repr__ first (it backs str, the shell, and containers), and remember the two traps: lists print items with __repr__, and == without __eq__ compares identity. Next session we build a full small class using all of these together.

Sources

  1. Python 3 Data Model - Special method names
  2. Python 3 Data Model - object.__repr__ and object.__str__
  3. Python 3 Library - repr() and len()
  4. All snippets, outputs, and error messages executed and copied from CPython 3.12. — Author verification run, 2026-07-15 (Python Fundamentals series, Session 25).

Want this taught 1-on-1? Alexander tutors Python Fundamentals — $55/session, free consultation.

Book on Wyzant · Text (657) 465-8108