DevHireLab
Tutorials
BootcampProblemsCode SimulatorAI InterviewSoonContact
DevHireLab
Tutorials
BootcampProblemsCode SimulatorAI InterviewSoonContact
Getting Started
01
What is Python and Why Learn It in 2026
02
Installing Python on Windows, Mac, and Linux
03
Setting up VS Code / PyCharm for Python
04
Understanding the Python Interpreter and REPL
05
Writing and Running Your First Python Program
06
Python Syntax and Indentation Rules
Variables and Data Types
01
Variables and Naming Conventions in Python
02
Numbers in Python (int, float, complex)
03
Strings and String Methods in Python
04
Booleans in Python
05
Type Conversion / Casting in Python
06
Understanding the None Type in Python
Operators
01
Arithmetic Operators in Python
02
Comparison Operators in Python
03
Logical Operators in Python
04
Assignment Operators in Python
05
Bitwise Operators in Python
06
Identity (is) and Membership (in) Operators
07
Walrus Operator (:=) in Python
Input, Output & Strings
01
input() and print() Functions in Python
02
f-strings and String Formatting in Python
03
String Slicing and Indexing in Python
04
Common String Methods (split, join, strip, replace)
05
Escape Characters in Python Strings
Control Flow
01
if, elif, else Statements in Python
02
Nested Conditionals in Python
03
for Loops in Python
04
while Loops in Python
05
break, continue, and pass Statements
06
Loop with else Clause in Python
07
match-case (Structural Pattern Matching)
Data Structures
01
Python Lists: Creation, Indexing, Slicing, Methods
02
Python Tuples and Immutability
03
Python Sets and Set Operations
04
Python Dictionaries: Keys, Values, and Methods
05
List Comprehensions in Python
06
Dictionary & Set Comprehensions in Python
07
Working with Nested Lists and Dictionaries
Functions
01
Defining and Calling Functions in Python
02
Function Arguments: Positional, Keyword, Default
03
*args and **kwargs in Python
04
Return Statements in Python Functions
05
Lambda Functions in Python
06
Recursion in Python
07
Variable Scope: Local, Global, Nonlocal
08
Docstrings and Function Annotations
Functional Programming Basics
01
map(), filter(), and reduce() in Python
02
Closures in Python Explained
03
Python Decorators: Basic to Advanced
04
Generators and yield in Python
Object-Oriented Programming (OOP)
01
Classes and Objects in Python
02
The init Constructor in Python
03
Instance vs Class Variables in Python
04
Inheritance in Python (Single, Multiple, Multilevel)
05
Polymorphism in Python
06
Encapsulation in Python
07
Abstraction in Python with the abc Module
08
Magic/Dunder Methods in Python
09
Static and Class Methods in Python
10
The @property Decorator in Python
11
Python Dataclasses Explained
Error Handling
01
try, except, finally in Python
02
Handling Multiple Exceptions in Python
03
Raising Custom Exceptions in Python
04
Understanding Python's Exception Hierarchy
05
Assertions in Python
File Handling
01
Reading and Writing Text Files in Python
02
Working with the with Statement in Python
03
Working with CSV Files in Python
04
Working with JSON Files in Python
05
Working with Directories (os, pathlib) in Python
Modules and Packages
01
Importing Built-in Modules in Python
02
Creating Your Own Modules in Python
03
Understanding Packages and init.py
04
Python Standard Library Overview
05
Installing Packages with pip
Advanced Core Concepts
01
Iterators and Iterables in Python
02
Context Managers in Python (with, contextlib)
03
Multithreading Basics in Python
04
Multiprocessing Basics in Python
05
Asyncio Basics: async/await in Python
06
Memory Management and Garbage Collection in Python
07
Type Hints in Python (typing module)

Magic/Dunder Methods in Python

python magic methods — also called python dunder methods, short for "double underscore" — are what let a print(obj) call, a + operator, or a len() call all work correctly on objects of your own custom classes. This article covers the most important ones: string representation, comparisons, arithmetic overloading, and the container/callable protocols that make your objects behave like Python's own built-in types.

Introduction: The Hooks Behind Python's Syntax

Dunder methods are special methods, named with double underscores on both sides (__method__), that Python calls automatically behind familiar syntax you already use constantly. print(obj) triggers obj.__str__(). obj + other triggers obj.__add__(other). len(obj) triggers obj.__len__().

Why this matters

Dunder methods are precisely how custom classes integrate seamlessly with Python's built-in operators and functions, rather than requiring special-cased handling scattered throughout your code. Without them, every custom class would need its own bespoke methods (.add(other), .to_string()) that behave completely differently from Python's native syntax — dunder methods let your own objects participate in the exact same +, print(), len(), in syntax that every built-in type already uses.

A quick way to explore them

Every built-in type already implements a large set of these — you can see them directly:

print(dir(int))
# ['__abs__', '__add__', '__and__', '__bool__', '__ceil__', ...]

Scanning through the output for any built-in type gives you a genuine sense of just how much of Python's "native" behavior is actually implemented through this same dunder method mechanism, all the way down.

String Representation: str and repr

The distinction

Two methods control how an object turns into a string, and they serve genuinely different purposes:

  • __repr__ should be unambiguous — ideally, something that could be used to recreate the object if pasted back into Python. It's aimed primarily at developers, especially when debugging.

  • __str__ is a readable, user-facing display — meant for people actually using the program, not necessarily anyone debugging its internals.

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

    def __repr__(self):
        return f"Point(x={self.x}, y={self.y})"   # unambiguous, debugger-friendly

    def __str__(self):
        return f"({self.x}, {self.y})"             # clean, user-facing display

p = Point(3, 4)
print(p)         # (3, 4) — uses __str__
print(repr(p))   # Point(x=3, y=4) — uses __repr__
Fallback behavior

If a class defines __repr__ but not __str__, Python automatically falls back to using __repr__ wherever __str__ would otherwise be needed:

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

    def __repr__(self):
        return f"Point(x={self.x}, y={self.y})"
    # no __str__ defined

p = Point(3, 4)
print(p)   # Point(x=3, y=4) — falls back to __repr__, since __str__ isn't defined

This is a genuinely practical reason to always implement __repr__ on a custom class, even if you also plan to implement __str__ separately — it acts as a sensible fallback, and it's directly useful anytime you're inspecting an object in a debugger, a REPL session, or inside a list's printed output (which always uses each item's __repr__, never its __str__).

Comparison Methods: eq, lt, and total_ordering

Giving meaning to ==

By default, == on custom objects falls back to identity comparison — effectively behaving like is, checking whether two variables point to the exact same object, rather than comparing their actual data:

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

p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1 == p2)   # False — default behavior, compares identity, not values

Implementing __eq__ lets you define what "equal" genuinely means for your class:

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

p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1 == p2)   # True — now compares actual coordinate values
The eq / hash pairing

This is a genuinely important pitfall worth knowing well: implementing __eq__ without also implementing __hash__ makes your objects unhashable — Python automatically sets __hash__ to None on any class that defines __eq__ but doesn't also explicitly define __hash__, which breaks that class's ability to be used in a set or as a dictionary key:

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
    # no __hash__ defined

p = Point(1, 2)
my_set = {p}
# TypeError: unhashable type: 'Point'

If you need instances of a class to remain hashable after implementing __eq__, you need to explicitly define __hash__ too — typically based on the same fields used in the equality check:

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

    def __hash__(self):
        return hash((self.x, self.y))

p1 = Point(1, 2)
my_set = {p1}   # works correctly now
functools.total_ordering

Defining every comparison method (__lt__, __le__, __gt__, __ge__) individually is repetitive. functools.total_ordering lets you define just __eq__ and one ordering method — commonly __lt__ — and it automatically generates the rest:

from functools import total_ordering

@total_ordering
class Money:
    def __init__(self, amount):
        self.amount = amount

    def __eq__(self, other):
        return self.amount == other.amount

    def __lt__(self, other):
        return self.amount < other.amount

amounts = [Money(50), Money(10), Money(30)]
sorted_amounts = sorted(amounts)   # works correctly — sorted() relies on comparison methods
print([m.amount for m in sorted_amounts])   # [10, 30, 50]

@total_ordering filled in __le__, __gt__, and __ge__ automatically, purely from the __eq__ and __lt__ you defined directly — which is exactly what let sorted() work correctly on a list of Money objects, without you needing to write all four comparison methods by hand.

Best practice: return NotImplemented

When a comparison method receives an operand of a type it genuinely doesn't know how to compare against, the correct response is NotImplemented (a special built-in singleton value) — not False, and not raising an exception directly:

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

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

Returning NotImplemented tells Python "I don't know how to handle this comparison — please try the other object's reflected method instead" (for instance, other.__eq__(self)), giving the other type a genuine chance to handle the comparison correctly, rather than Python assuming a false answer purely because the first object's method happened to give up.

Arithmetic Operator Overloading

Making custom objects work with + , -, *

Implementing __add__, __sub__, __mul__, and similar dunder methods lets a custom, numeric-like class (money, vectors, physical distances) work naturally with Python's arithmetic operators, exactly as covered briefly in the earlier polymorphism article:

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

    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2)   # Vector(4, 6)
A practical example with validation: Money
class Money:
    def __init__(self, amount, currency="USD"):
        self.amount = amount
        self.currency = currency

    def __add__(self, other):
        if not isinstance(other, Money):
            return NotImplemented
        if self.currency != other.currency:
            raise ValueError(f"Cannot add {self.currency} and {other.currency}")
        return Money(self.amount + other.amount, self.currency)

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

usd1 = Money(50, "USD")
usd2 = Money(30, "USD")
print(usd1 + usd2)   # 80 USD

eur = Money(20, "EUR")
usd1 + eur
# ValueError: Cannot add USD and EUR

This is a genuinely realistic use case — __add__ enforces a real business rule (matching currencies) as part of the operator overload itself, meaning usd1 + eur fails loudly and immediately, rather than silently producing a nonsensical combined amount.

The same NotImplemented convention applies

Just as with comparison methods, returning NotImplemented from an arithmetic dunder method (rather than raising an exception directly, or silently returning something incorrect) lets Python fall back and try the other operand's reflected method — __radd__ in the case of addition — giving mixed-type arithmetic a genuine chance to work correctly when it reasonably should.

Container and Callable Protocols

len, getitem, and contains

These three dunder methods let a custom object behave like a built-in sequence — supporting len(), bracket indexing, and the in operator:

class Playlist:
    def __init__(self, songs):
        self.s songs

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

    def __getitem__(self, index):
        return self.songs[index]

    def __contains__(self, song):
        return song in self.songs

playlist = Playlist(["Song A", "Song B", "Song C"])

print(len(playlist))          # 3 — len() works
print(playlist[1])            # Song B — bracket indexing works
print("Song A" in playlist)   # True — the in operator works

None of len(), playlist[1], or "Song A" in playlist would work at all on a plain custom class without these three methods explicitly defined — they're what makes Playlist genuinely feel like a native Python sequence from the outside, even though it's backed by a completely ordinary custom class internally.

bool and its fallback to len

__bool__ controls how an object behaves in a truthiness check (if obj:). If __bool__ isn't defined, Python falls back to __len__ instead — an object with a length of 0 is treated as falsy, and any nonzero length is treated as truthy:

class Playlist:
    def __init__(self, songs):
        self.s songs

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

empty_playlist = Playlist([])
full_playlist = Playlist(["Song A"])

print(bool(empty_playlist))   # False — falls back to __len__, which is 0
print(bool(full_playlist))    # True — __len__ is 1, nonzero

if empty_playlist:
    print("Has songs")
else:
    print("Empty playlist")
# Empty playlist

This mirrors exactly the truthy/falsy behavior covered for built-in types (like empty lists and strings) in the earlier booleans article — __bool__/__len__ are what actually implement that behavior under the hood, and defining them on your own classes extends the exact same pattern to your custom objects.

call: making instances callable

__call__ lets you call an instance of a class directly, using regular function-call syntax:

class Multiplier:
    def __init__(self, factor):
        self.factor = factor

    def __call__(self, value):
        return value * self.factor

double = Multiplier(2)
print(double(5))   # 10 — calling the instance directly, like a function

This is genuinely useful for stateful callables — objects that behave like a function from the outside, but internally maintain some configuration or state, exactly like factor here. It's also precisely the mechanism behind class-based decorators, covered in the earlier decorators article — the CallCounter class shown there relies entirely on __call__ to make its instances behave like a wrapped function.

Closing guidance

Implement dunder methods gradually, and only where they genuinely add clarity — not reflexively, on every class you write, purely because you now know they exist. A class that clearly represents something numeric, sequence-like, or naturally comparable is a good candidate for the relevant dunder methods; a class that doesn't naturally fit any of those patterns generally shouldn't force them in artificially. When you're implementing one and aren't sure of the exact expected behavior or signature, Python's official documentation — specifically the "Special Method Names" section of the data model reference — remains the definitive, authoritative source for getting the details right.

PREVIOUSNEXT LESSON