datarekha

Dunder methods

__repr__, __eq__, __hash__, __len__, __iter__, __call__ — the special methods that make your classes behave like Python's built-ins.

10 min read Intermediate Python Lesson 27 of 41

What you'll learn

  • The dunder methods worth knowing — __repr__, __str__, __eq__, __hash__
  • __len__, __iter__, __getitem__ for container-like classes
  • __enter__/__exit__ for context managers, __call__ for callables
  • Building a Money value object that works in sets and dicts

Before you start

The so-called “magic” methods are really just an interface. They are called dunder methods because their names carry double underscores on each side — __len__, __eq__, __iter__ — and they are how Python’s operators and built-in functions hook into your classes. len(x) is really x.__len__(); x == y is x.__eq__(y); for item in x: is x.__iter__(). Learn the protocol and your own classes can behave like lists, sets, numbers, or callables — first-class citizens of the language rather than awkward outsiders.

len(x) lenx == y eqx + y addfor _ in x iterx[i] getitemx() call

repr — every class deserves one

If you write a single dunder on a class, make it __repr__. It controls what you see when you print the object or inspect it in a debugger, and the default is genuinely useless:

class UserBad:
    def __init__(self, id, name):
        self.id = id
        self.name = name

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

print(UserBad(1, "Aarav"))      # the default — useless
print(UserGood(1, "Aarav"))     # a custom repr — helpful
print([UserGood(1, "Aarav"), UserGood(2, "Priya")])
<__main__.UserBad object at 0x...>
User(id=1, name='Aarav')
[User(id=1, name='Aarav'), User(id=2, name='Priya')]

The 0x... in the first line is a memory address that changes every run — it tells you nothing about which user this is. Contrast User(id=1, name='Aarav'): anyone scanning a log or stepping through a debugger reads it instantly. The convention is that __repr__ should look like the constructor call that would recreate the object.

A close cousin is __str__. repr(obj) (developer-facing, unambiguous) calls __repr__; str(obj) (user-facing) calls __str__, but falls back to __repr__ if you have not defined it. For most internal classes, a good __repr__ alone is plenty; add __str__ only when the friendly form should differ from the debug form.

eq and hash — the inseparable pair

By default, two distinct objects are never equal even when every field matches, because Python compares identity (memory address). For value objects — money, points, IDs — that is almost always wrong:

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

print("equal?", PointBad(1, 2) == PointBad(1, 2))   # same fields...

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def __repr__(self):
        return f"Point({self.x}, {self.y})"
    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return (self.x, self.y) == (other.x, other.y)
    def __hash__(self):
        return hash((self.x, self.y))

a, b, c = Point(1, 2), Point(1, 2), Point(3, 4)
print("equal?", a == b)
print("set size:", len({a, b, c}))   # a and b dedup -> 2 distinct
equal? False
equal? True
set size: 2

Two rules make this safe. First, if __eq__ compares the tuple (self.x, self.y), then __hash__ must hash the same tuple — equal objects are required to share a hash, or they break set and dict. (That is why len({a, b, c}) is 2: a and b are equal and hash alike, so the set folds them together.) Second, return NotImplemented when compared against an unrelated type, which lets Python try the other operand’s __eq__ instead of forcing a wrong answer.

A real value object — Money

Money is the textbook case: equality should compare amount and currency, the hash should let it live in a set, the repr should read clearly, and addition across currencies should refuse rather than lie. Built on Decimal, not floats:

from decimal import Decimal

class Money:
    def __init__(self, amount, currency):
        self.amount = Decimal(str(amount))   # Decimal — exact money arithmetic
        self.currency = currency.upper()

    def __repr__(self):
        return f"Money({self.amount}, {self.currency!r})"

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

    def __hash__(self):
        return hash((self.amount, self.currency))

    def __add__(self, other):
        if not isinstance(other, Money) or self.currency != other.currency:
            raise ValueError("can't add different currencies (need an exchange rate)")
        return Money(self.amount + other.amount, self.currency)

a = Money("9.99", "USD")
b = Money("9.99", "USD")
c = Money("9.99", "EUR")

print(a == b)               # same amount and currency
print(a == c)               # different currency
print("set size:", len({a, b, c}))
print(a + b)

prices = {Money("10", "USD"): "small", Money("25", "USD"): "large"}
print(prices[Money("25", "USD")])    # Money works as a dict key
True
False
set size: 2
Money(19.98, 'USD')
large

So the prediction resolves to 2: a and b are equal and hash alike, while c differs by currency. And because __eq__/__hash__ are consistent, a freshly built Money("25", "USD") looks up the right value in prices — the object is a true value, identity forgotten.

Container-like classes

If a class wraps a collection, a few dunders make it work natively with len(), in, indexing, and for:

class Inventory:
    def __init__(self, items):
        self._items = list(items)
    def __repr__(self):
        return f"Inventory({self._items})"
    def __len__(self):
        return len(self._items)
    def __iter__(self):
        return iter(self._items)
    def __getitem__(self, index):
        return self._items[index]
    def __contains__(self, item):
        return item in self._items

inv = Inventory(["apple", "banana", "cherry"])
print(len(inv))
print(inv[1])
print("apple" in inv)
for item in inv:
    print(" -", item)
3
banana
True
 - apple
 - banana
 - cherry

Each built-in just delegated to its dunder: len(inv) to __len__, inv[1] to __getitem__, in to __contains__, and the for loop to __iter__. (Implementing __getitem__ with integer indices alone is actually enough to make a class iterable via Python’s fallback protocol, but an explicit __iter__ is clearer and works with any storage.)

Context managers and callables

Two more dunders unlock two more language features. A class with __enter__ and __exit__ works in a with block — here a deterministic tracer that prints when it enters and exits:

class Section:
    def __init__(self, label):
        self.label = label
    def __enter__(self):
        print(f"enter {self.label}")
        return self
    def __exit__(self, exc_type, exc_val, exc_tb):
        print(f"exit  {self.label}")
        # return True to suppress an exception; False/None to let it propagate

with Section("compute") as s:
    total = sum(i * i for i in range(5))
print("total:", total)
enter compute
exit  compute
total: 30

And __call__ makes an instance callable like a function — perfect for a stateful “function” that remembers things between calls:

class Counter:
    def __init__(self):
        self.n = 0
    def __call__(self):
        self.n += 1
        return self.n

count = Counter()
print(count(), count(), count())     # call the instance directly
print("total:", count.n)
1 2 3
total: 3

That last pattern is exactly how a PyTorch model is invoked — model(x) is an instance call routed through __call__, not a plain function. The dunder protocols are precisely how libraries make their objects feel like built-in language features: Pandas DataFrames respond to [], len, and for; SQLAlchemy turns User.name == "Aarav" into a SQL WHERE clause through __eq__.

In one breath

  • Write __repr__ on every class — the default shows a useless memory address.
  • __eq__ makes value objects compare by field; pair it with a consistent __hash__ or you break set/dict.
  • Return NotImplemented from __eq__ for unrelated types.
  • __len__, __iter__, __getitem__, __contains__ make a class behave like a container.
  • __enter__/__exit__ power with; __call__ makes an instance callable like a function.

Practice

Quick check

0/3
Q1Why is `__repr__` worth writing on every class?
Q2Why must __eq__ and __hash__ agree?
Q3What does `__call__` let you do?

What’s next

Writing all these dunders by hand is tedious. Next comes dataclasses — a decorator that writes __init__, __repr__, and __eq__ for you, so you only fill in the interesting parts.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
What is the contract between `__eq__` and `__hash__` in Python, and what breaks when you define only one?

Objects that compare equal must have identical hash values. Python enforces this expectation by setting `__hash__` to `None` whenever you define `__eq__` without `__hash__`, making the object unhashable and ineligible as a dict key or set member. To restore hashability you must define both.

What is the difference between `__str__` and `__repr__` in Python, and which should you implement first?

`__repr__` is the developer-facing representation — unambiguous, ideally eval-able back to the object. `__str__` is the user-facing string — readable and concise. When only `__repr__` is defined, Python falls back to it for `str()` as well, so implement `__repr__` first.

What set operations does Python support, and where are they practically useful in data work?

Python sets support union, intersection, difference, and symmetric difference as both operators and methods, all running in O(min(m,n)) to O(m+n) time. They are useful for deduplication, membership testing in large collections, and computing overlaps between datasets — operations that would be expensive with lists.

What is the difference between an instance method, a class method, and a static method in Python?

Instance methods receive the instance as the first argument (`self`) and can read and modify instance state. Class methods receive the class as the first argument (`cls`) via `@classmethod` and are used for alternative constructors or class-level operations. Static methods receive no implicit argument and are plain functions namespaced inside a class.

Related lessons

Explore further

Skip to content