x + y, len(x), x == y, str(x) ā none of these are hardcoded to work on specific types. Every one of them is a call to a dunder (double-underscore) method that your own class can implement, hooking directly into Python's own operators and built-in functions. This is called the data model, and it's the single most reused idea in idiomatic Python.
1__repr__, __str__, and Two Kinds of "As a String"
Python distinguishes two different string representations of an object, each with its own dunder and its own audience. __repr__ should produce an unambiguous representation aimed at *developers* ā ideally one that, if you eval()'d it, would recreate an equal object (Money(20, 'USD'), following that convention closely) ā and is what's shown in a REPL, in a list's repr ([Money(20, 'USD')]), and used as a fallback for print() and str() if __str__ is not separately defined.
__str__, when explicitly defined, is what str(obj) and print(obj) use instead ā aimed at *end users*, and free to be more casual or domain-specific ('$20.00' rather than Money(20, 'USD')). If you only implement one, implement __repr__ ā it's the one with a fallback role, so getting it right benefits every context, while a class with only __str__ still shows an unhelpful default repr in a debugger or REPL.
The !r in f"Money({self.amount!r}, {self.currency!r})" explicitly calls repr() on each field rather than str(), which matters specifically for the currency string ā without !r, the f-string would produce Money(20, USD) (no quotes, ambiguous, and not valid Python if you tried to eval it) instead of the more useful Money(20, 'USD').
class Money:
def __init__(self, amount: float, currency: str):
self.amount = amount
self.currency = currency
print(Money(20, "USD")) # <__main__.Money object at 0x7f...> ā useless
class Money:
def __init__(self, amount, currency):
self.amount, self.currency = amount, currency
def __repr__(self):
return f"Money({self.amount!r}, {self.currency!r})"
print(Money(20, "USD")) # Money(20, 'USD')Money(20, 'USD')
2__eq__, NotImplemented, and Why Arithmetic Dunders Return It Instead of Raising
The default __eq__, inherited from object, compares by identity ā equivalent to is ā which is almost never what you want for a value-like class such as Money. Defining __eq__ to compare self.amount and self.currency against other's makes two separately-constructed but data-identical instances compare equal, matching how most developers intuitively expect == to behave for value objects (this is, not coincidentally, exactly what @dataclass's auto-generated __eq__ does for you, as covered in the Dataclasses lesson).
The isinstance(other, Money) guard, combined with returning NotImplemented (a specific singleton, not raising an exception and not returning the boolean False) when the check fails, is the correct protocol for every binary dunder ā __eq__, __add__, __lt__, and friends. Returning NotImplemented tells Python 'I personally don't know how to handle this combination of types' rather than definitively asserting an answer; Python then gives the *other* object a chance to handle the operation via its own reflected method (__radd__ for +, for instance) before finally raising a standard TypeError if neither side can handle it.
Getting this wrong ā returning False from __eq__ instead of NotImplemented when types don't match, for example ā silently produces wrong answers in edge cases involving comparison against unrelated types, instead of cleanly delegating or failing loudly.
class Money:
def __init__(self, amount, currency):
self.amount, self.currency = amount, currency
def __eq__(self, other):
if not isinstance(other, Money):
return NotImplemented
return self.amount == other.amount and self.currency == other.currency
print(Money(20, "USD") == Money(20, "USD")) # TrueNotImplemented ā Python falls back to False, not a crash
3Hooking Into Operators and Built-Ins: __add__, __len__, __getitem__
__add__(self, other) is called whenever your object is the left-hand operand of +; implementing it ā with the same NotImplemented-on-mismatch protocol as __eq__ ā is what lets Money(20, "USD") + Money(5, "USD") work with domain-specific rules (here, refusing to silently add mismatched currencies rather than producing a meaningless combined total). The broader family ā __sub__, __mul__, __truediv__, and their reflected counterparts __radd__, __rsub__, etc. ā follows the identical pattern for every arithmetic operator.
__len__ and __getitem__ are what let a custom class integrate with len() and square-bracket indexing (obj[i]) respectively ā and, notably, implementing __getitem__ alone (returning IndexError once the index runs out) is actually enough to make an object iterable via the classic sequence protocol, even without a separate __iter__, since Python falls back to trying sequential integer indexing starting at 0 if no __iter__ is found.
The unifying idea across every dunder method ā __repr__, __eq__, __add__, __len__, __getitem__, and dozens more (__contains__ for in, __call__ for calling an instance like a function, __enter__/__exit__ from the Context Managers lesson) ā is that Python's syntax and built-in functions are not special-cased for built-in types at all. They're a consistent protocol any class can opt into, which is exactly why @dataclass, Enum, descriptors, and context managers all work by implementing exactly this same family of methods.
class Money:
def __add__(self, other):
if not isinstance(other, Money) or other.currency != self.currency:
return NotImplemented
return Money(self.amount + other.amount, self.currency)
total = Money(20, "USD") + Money(5, "USD") # Money(25, 'USD')Both route through your class's __len__/__getitem__
4Step-by-Step Breakdown
print(my_object) and my_object + other_object aren't special-cased for built-in types ā they call methods your own classes can implement too. Let's give our classes that superpower.
Without __repr__, printing an object gives an unhelpful memory address. __repr__ controls that string, and __str__ controls print()'s output specifically.
__eq__ controls == comparison. Without it, two objects with identical data still compare unequal, because the default is identity comparison.
Checkpoint: Without a custom __eq__, does Money(20, "USD") == Money(20, "USD") return True or False?
- āFalse ā the default __eq__ compares by identity (are they the same object?)
- āTrue ā Python always compares objects by their attribute values
__add__ lets your objects use the + operator, with domain-specific rules ā here, refusing to add mismatched currencies.
Checkpoint: What should __add__ return when adding two Money objects of DIFFERENT currencies?
- āNotImplemented ā lets Python raise a clear TypeError or try the other operand's __radd__
- āA Money object with amount 0, silently
__len__ and __getitem__ let your class work with len() and square-bracket indexing, just like a list.
Magic methods let ordinary objects opt into operators and built-ins ā metaclasses take that same customization one level up, letting you control how classes themselves are constructed.
Control a Real repr Output. Finish Money.__repr__(): __repr__ controls exactly what repr()/print() shows.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Always implement __repr__ before __str__ if you can only pick one
__repr__ has the fallback role (used by print/str if __str__ is absent, and always used in REPLs, debuggers, and containers), so it benefits every context ā a class with only __str__ still shows an unhelpful default repr elsewhere.
Return NotImplemented, never raise or return False, when a binary dunder receives an incompatible type
This lets Python correctly fall back to the other operand's reflected method or raise a standard, clear TypeError ā silently returning False from __eq__ on a type mismatch can produce confusing, wrong answers instead of a clean failure.
Frequent Bugs
Defining __eq__ without also defining __hash__, then trying to use instances of that class as dict keys or in a set ā Python makes a class unhashable by default the moment you add __eq__.
Explicitly define __hash__ (often hash((self.amount, self.currency))) alongside a custom __eq__ if instances need to be hashable, or accept that the class is unhashable if it's meant to be a mutable, non-key type.
Real-World Examples
A Vector Class Supporting +, ==, and repr for a Physics Simulation
A 2D vector class needs to support natural arithmetic syntax (v1 + v2), meaningful equality comparison, and readable debugging output, matching how NumPy arrays and similar numeric types behave.
class Vector2D:
def __init__(self, x: float, y: float):
self.x, self.y = x, y
def __repr__(self):
return f"Vector2D({self.x!r}, {self.y!r})"
def __eq__(self, other):
if not isinstance(other, Vector2D):
return NotImplemented
return self.x == other.x and self.y == other.y
def __add__(self, other):
if not isinstance(other, Vector2D):
return NotImplemented
return Vector2D(self.x + other.x, self.y + other.y)