Dunder Methods and the Data Model

Updated

September 8, 2026

Dunder Methods and the Data Model

Python’s operators and builtins are method calls with nicer spelling. len(x) is x.__len__(). a == b is a.__eq__(b). print(x) uses x.__repr__() unless you also wrote __str__. The boring default is to implement the few methods callers actually use — not a complete “numeric type” or a fake sequence.

Mental model

A dunder (double underscore) method is a hook the language looks up by name. The set of those hooks is the data model: how your type talks to for, +, repr, containers, and so on.

You do not register anything. You define the method. Python calls it.

Return NotImplemented (the singleton, not an exception) when an operator does not know the other type. Python can then try the other operand. Returning False from __eq__ for every foreign object shuts that down.

Do not implement a dunder because a list of magic methods looks thorough. Each one is an API you now have to keep honest.

Worked examples

Case 1: __repr__ that looks like a constructor

Save as ticket_repr.py. One debug form is enough for a desk record. Skip __str__ until you have a sentence for a person.

# ticket_repr.py
class Ticket:
    def __init__(self, id, table):
        self.id = id
        self.table = table

    def __repr__(self):
        return f"Ticket(id={self.id!r}, table={self.table!r})"


def main():
    t = Ticket(7, 12)
    print(t)
    print(repr(t))


if __name__ == "__main__":
    main()

Run:

uv run python ticket_repr.py

Output:

Ticket(id=7, table=12)
Ticket(id=7, table=12)

print uses __str__ if you wrote it, otherwise __repr__. {self.id!r} puts quotes around strings so a table named 12 and a table numbered 12 stay distinguishable.

Case 2: __eq__ for the identity you mean

Save as ticket_eq.py. Two tickets with the same id are the same ticket even if the table changed.

# ticket_eq.py
class Ticket:
    def __init__(self, id, table):
        self.id = id
        self.table = table

    def __repr__(self):
        return f"Ticket(id={self.id!r}, table={self.table!r})"

    def __eq__(self, other):
        if not isinstance(other, Ticket):
            return NotImplemented
        return self.id == other.id


def main():
    a = Ticket(7, 12)
    b = Ticket(7, 4)
    c = Ticket(8, 12)
    print(a == b)
    print(a == c)
    print(a == 7)


if __name__ == "__main__":
    main()

Run:

uv run python ticket_eq.py

Output:

True
False
False

a == 7 is False because Ticket returns NotImplemented and int does not know tickets. That is the right shape. Do not special-case int here.

Case 3: __len__ and __iter__ for a small collection

Save as shift_board.py. If the object is a list of shifts, let len and for work. You do not need __getitem__ until someone indexes it.

# shift_board.py
class Shift:
    def __init__(self, name, hours):
        self.name = name
        self.hours = hours

    def __repr__(self):
        return f"Shift({self.name!r}, {self.hours})"


class ShiftBoard:
    def __init__(self, shifts):
        self._shifts = list(shifts)

    def __len__(self):
        return len(self._shifts)

    def __iter__(self):
        return iter(self._shifts)


def main():
    board = ShiftBoard(
        [
            Shift("open", 4),
            Shift("mid", 6),
            Shift("close", 5),
        ]
    )
    print(len(board))
    for shift in board:
        print(shift)


if __name__ == "__main__":
    main()

Run:

uv run python shift_board.py

Output:

3
Shift('open', 4)
Shift('mid', 6)
Shift('close', 5)

__iter__ can return iter(self._shifts). Do not hand-write a custom iterator class for a list you already own.

Case 4: one operator, for an amount

Save as order_cents.py. Money-as-cents is a type that can add to itself. It cannot add to a bare int until you decide what that means.

# order_cents.py
class Cents:
    def __init__(self, amount):
        if amount < 0:
            raise ValueError("cents cannot be negative")
        self.amount = amount

    def __repr__(self):
        return f"Cents({self.amount})"

    def __add__(self, other):
        if not isinstance(other, Cents):
            return NotImplemented
        return Cents(self.amount + other.amount)

    def __eq__(self, other):
        if not isinstance(other, Cents):
            return NotImplemented
        return self.amount == other.amount


def main():
    soup = Cents(450)
    bread = Cents(200)
    total = soup + bread
    print(total)
    print(total == Cents(650))
    try:
        soup + 200
    except TypeError as e:
        print(type(e).__name__)


if __name__ == "__main__":
    main()

Run:

uv run python order_cents.py

Output:

Cents(650)
True
TypeError

Skip __iadd__, __radd__, __sub__, __mul__ until a caller needs them. __add__ already covers total = soup + bread. In-place += will use __add__ and rebind the name.

The trap

Two common overreaches: silent attributes, and equality that breaks sets.

Save as noisy_ticket.py. __getattr__ runs for missing names. A typo becomes a string instead of AttributeError.

# noisy_ticket.py
class Ticket:
    def __init__(self, id, table):
        self.id = id
        self.table = table

    def __bool__(self):
        return self.table != 0

    def __getattr__(self, name):
        return f"missing:{name}"


def main():
    t = Ticket(7, 12)
    print(bool(t))
    print(t.table)
    print(t.tabl)


if __name__ == "__main__":
    main()

Run:

uv run python noisy_ticket.py

Output:

True
12
missing:tabl

t.table still works (__getattr__ is not used when the attribute exists). t.tabl does not fail. Delete __getattr__. Let the typo raise.

The other overreach: __eq__ without a story for hashing.

# unhashable.py
class Ticket:
    def __init__(self, id):
        self.id = id

    def __eq__(self, other):
        if not isinstance(other, Ticket):
            return NotImplemented
        return self.id == other.id


def main():
    try:
        {Ticket(7), Ticket(8)}
    except TypeError as e:
        print(type(e).__name__)
        print(e)


if __name__ == "__main__":
    main()

Run:

uv run python unhashable.py

Output:

TypeError
cannot use 'Ticket' as a set element (unhashable type: 'Ticket')

User-defined __eq__ makes the type unhashable unless you also define __hash__. That is a feature: mutable records should not be set elements. Key a dict by ticket.id instead.

The boring rule

  • Write __repr__ first. Make it look like a call that would rebuild the object.
  • Write __eq__ only when value equality is real. Return NotImplemented for other types.
  • Write __len__ and __iter__ when the object is a collection. Stop there until indexing is a real need.
  • Write __add__ (and friends) only for amounts. One operator is a type. Twelve operators are a science project.
  • Do not implement __getattr__, __bool__, or __str__ “while you are here.”
  • If you write __eq__, either keep the type out of sets or define __hash__ on an immutable value. Default: dict keyed by id.

Try this

  1. In ticket_repr.py, add __str__ that returns ticket 7 @ table 12. Print both t and repr(t).
  2. In shift_board.py, add a function total_hours(board) that loops with for and sums shift.hours.
  3. In order_cents.py, add __mul__ so Cents(450) * 2 is Cents(900). Reject a non-int with NotImplemented.
  4. Remove __getattr__ from noisy_ticket.py and confirm t.tabl raises AttributeError.