Technology  /  Python

🐍 Python 78 guides · updated 2026

From first variable to OOP, generators, and real projects — the language that runs everything from data pipelines to AI agents, taught the practical way.

Python Magic Methods: Dunder Methods That Make Objects Feel Native

When you call len(some_list) or write a + b, Python does not have special-cased handling for built-in types. Instead, it calls a method on the object: some_list.__len__() and a.__add__(b). These dunder methods (double underscore, also called magic methods or special methods) are how Python makes its built-in syntax extensible. Any class can implement them, and Python will treat instances of that class like first-class objects.

__str__ and __repr__: Two Kinds of String Output

Both methods control how an object is converted to a string, but for different audiences.

class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return f"({self.x}, {self.y})"
def __repr__(self):
return f"Vector({self.x!r}, {self.y!r})"
v = Vector(3, 4)
print(v) # (3, 4) — uses __str__
print(repr(v)) # Vector(3, 4) — uses __repr__
# In interactive mode or debuggers, repr is shown
vectors = [Vector(1, 0), Vector(0, 1)]
print(vectors) # [Vector(1, 0), Vector(0, 1)] — __repr__ for items in a list

If you only implement one, implement __repr__. Python falls back to __repr__ when __str__ is missing. The reverse is not true.

__add__, __sub__, __mul__: Arithmetic Operators

Implementing __add__ makes the + operator work on your objects.

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 __sub__(self, other):
return Vector(self.x - other.x, self.y - other.y)
def __mul__(self, scalar):
return Vector(self.x * scalar, self.y * scalar)
def __rmul__(self, scalar):
# Handles scalar * vector (not just vector * scalar)
return self.__mul__(scalar)
def __abs__(self):
return (self.x ** 2 + self.y ** 2) ** 0.5
def __repr__(self):
return f"Vector({self.x}, {self.y})"
a = Vector(1, 2)
b = Vector(3, 4)
print(a + b) # Vector(4, 6)
print(b - a) # Vector(2, 2)
print(a * 3) # Vector(3, 6)
print(3 * a) # Vector(3, 6) — calls __rmul__
print(abs(b)) # 5.0

The __r*__ variants (like __rmul__) handle cases where Python tries scalar.__mul__(vector) first, fails, and then tries vector.__rmul__(scalar).

__eq__, __lt__, __le__: Comparisons

By default, == checks identity (same object in memory). Override __eq__ to compare by value.

from functools import total_ordering
@total_ordering # generates __le__, __gt__, __ge__ from __eq__ and __lt__
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
def __eq__(self, other):
if not isinstance(other, Temperature):
return NotImplemented
return self.celsius == other.celsius
def __lt__(self, other):
if not isinstance(other, Temperature):
return NotImplemented
return self.celsius < other.celsius
def __repr__(self):
return f"Temperature({self.celsius}°C)"
temps = [Temperature(37), Temperature(100), Temperature(0), Temperature(22)]
print(sorted(temps))
# [Temperature(0°C), Temperature(22°C), Temperature(37°C), Temperature(100°C)]
print(Temperature(37) == Temperature(37)) # True
print(Temperature(37) > Temperature(0)) # True

@total_ordering from functools lets you define just __eq__ and __lt__, and it derives the other four comparison methods automatically.

Returning NotImplemented (not False) when the other object is of an incompatible type tells Python to try the reverse comparison on the other object.

__len__ and __getitem__: Making Objects Behave Like Sequences

class Deck:
SUITS = ("♠", "♥", "♦", "♣")
RANKS = list(range(2, 11)) + ["J", "Q", "K", "A"]
def __init__(self):
self._cards = [
f"{rank}{suit}"
for suit in self.SUITS
for rank in self.RANKS
]
def __len__(self):
return len(self._cards)
def __getitem__(self, index):
return self._cards[index]
deck = Deck()
print(len(deck)) # 52
print(deck[0]) # 2♠
print(deck[-1]) # A♣
print(deck[5:8]) # ['7♠', '8♠', '9♠']
# __len__ and __getitem__ also make the object iterable
for card in deck[:3]:
print(card)

Once you implement __getitem__, objects of that class become iterable (Python calls __getitem__ with increasing indices until it gets an IndexError). You also get slicing for free if the underlying data structure supports it.

__iter__ and __next__: Explicit Iterator Protocol

For more control, implement the full iterator protocol:

class CountUp:
def __init__(self, start, stop):
self.current = start
self.stop = stop
def __iter__(self):
return self # this object is its own iterator
def __next__(self):
if self.current > self.stop:
raise StopIteration
value = self.current
self.current += 1
return value
for n in CountUp(1, 5):
print(n, end=" ")
# 1 2 3 4 5

__enter__ and __exit__: Context Managers

These two methods make your class work with the with statement.

class Timer:
def __enter__(self):
import time
self._start = time.perf_counter()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
import time
self.elapsed = time.perf_counter() - self._start
print(f"Elapsed: {self.elapsed:.4f}s")
return False # do not suppress exceptions
with Timer() as t:
total = sum(range(1_000_000))
# Elapsed: 0.0234s

__exit__ receives exception information if one occurred. Returning True suppresses the exception; returning False (or None) lets it propagate.

Best Practices

Return NotImplemented from comparison and arithmetic methods when the types are incompatible — do not return False or raise TypeError yourself. This allows Python to try the reflected operation on the other object.

Keep dunder methods simple. They are called by Python’s infrastructure. Surprising side effects in __len__ or __eq__ make code very hard to debug.

Do not implement operator overloading unless it makes semantic sense. Vector + Vector is intuitive. User + User is probably not.

Always implement __repr__ on classes you create. The default <__main__.MyClass object at 0x7f...> is nearly useless during debugging.