šŸš€ LEVEL UP TO SENIOR:Unlock 500+ Advanced Practical Challenges & Exercises.
šŸŽ“ COURSERA PARTNER:Earn professional Google, Meta, and IBM certificates to supercharge your resume.
HTML MASTER CLASS /// LEARN TAGS /// BUILD STRUCTURE /// SEMANTIC WEB /// HTML MASTER CLASS /// LEARN TAGS ///

Python Magic Methods (Dunder Methods)

Make your own classes behave like built-ins — implement __repr__, __eq__, __len__, __add__, and the arithmetic/comparison dunders that plug directly into Python's syntax.

⚔ Total XP: 0|šŸ’» python XP: 0

Skill Matrix

UNLOCK NODES BY LEARNING NEW TAGS.

System Hub

Core logic.

Quick Quiz //

Without a custom __eq__, does Money(20, "USD") == Money(20, "USD") return True or False?


šŸš€ LEVEL UP TO SENIOR:Unlock 500+ Advanced Practical Challenges & Exercises.
šŸŽ“ COURSERA PARTNER:Earn professional Google, Meta, and IBM certificates to supercharge your resume.

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')
localhost:3000
Console Output
print(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"))  # True
localhost:3000
Comparison Protocol
Money(20,'USD') == 'not money'
NotImplemented → 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')
localhost:3000
Operator Overloading
len(playlist), playlist[0]
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

ChromeSupported

Fully supported (via server-side Python execution).

FirefoxSupported

Fully supported (via server-side Python execution).

SafariSupported

Fully supported (via server-side Python execution).

EdgeSupported

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

THE BUG

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

THE FIX

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)

Interview Prep

?Frequently Asked Questions

Pascual Vila

Pascual Vila

Frontend Instructor // Code Syllabus

Common Pitfalls & Errors

The Error //

Returning False (instead of NotImplemented) from __eq__ when the other operand is an incompatible type, producing subtly wrong results in edge cases involving reflected comparison.

# Wrong: silently asserts inequality even for unrelated types def __eq__(self, other): if not isinstance(other, Money): return False return self.amount == other.amount # Correct: lets Python's protocol handle it properly def __eq__(self, other): if not isinstance(other, Money): return NotImplemented return self.amount == other.amount and self.currency == other.currency

The Solution //

Return NotImplemented specifically when the type check fails, letting Python correctly fall back to the other object's comparison method or a standard equality-is-False default.

Lesson Glossary

[01]Dunder method

A "double underscore" method (e.g. __init__, __repr__, __add__) that Python calls implicitly to implement operators, built-ins, and language features on a class.

Code Preview
// Dunder method context

[02]Data model

Python's term (from the official documentation) for the complete set of dunder methods and the protocols they implement, unifying operators, built-ins, and control-flow statements.

Code Preview
// Data model context

[03]NotImplemented

A singleton value returned from a binary dunder method to signal "I cannot handle this operand type", letting Python try the reflected method or raise TypeError.

Code Preview
// NotImplemented context

[04]Reflected method

A dunder like __radd__, called on the right-hand operand when the left-hand operand's corresponding method (__add__) returns NotImplemented.

Code Preview
// Reflected method context

Continue Learning