Python

Magic Methods in Python

Intermediate

Magic Methods in Python

Magic Methods in Python: The Secret Sauce Behind Elegant Code

Ever wondered why you can write vector1 + vector2 , if user in users , or len(my_custom_list) and it just works beautifully? The answer is magic methods (also called dunder methods). These special methods let you define how your objects interact with Python’s built-in operators, functions, and syntax. They are the reason Python code can feel so natural and expressive. In this guide, we’ll explore the most important magic methods,  from comparisons and arithmetic to containers and beyond, with clear explanations and practical, ready-to-use examples.

Magic Methods in Python

Magic methods (also known as dunder methods, short for "double underscore") are special methods in Python that allow you to define how your objects behave with built-in operations and functions. They always start and end with double underscores (__), for example: __init__, __str__, __add__, __len__, etc.

Why they matter

  • They let you customize the behavior of your classes so they feel like built-in Python types.
  • They enable operator overloading (e.g., +, *, ==, [], etc.).
  • They hook into Python's core protocols (iteration, context managers, async, etc.).
Simple example:
class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

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

    def __str__(self):                  # print(vector) or str(vector)
        return f"Vector({self.x}, {self.y})"

    def __len__(self):                  # len(vector)
        return 2

#With these methods, you can write natural, readable code:

v1 = Vector(2, 3)
v2 = Vector(5, 7)
print(v1 + v2)   # Vector(7, 10)
print(len(v1))   # 2
Magic methods are the reason Python code can be so elegant and expressive. They're one of the most powerful features for creating intuitive APIs and domain-specific languages.

Comparison Magic Methods

__eq__(self, other)
Defines behavior for the equality operator, ==.
__ne__(self, other)
Defines behavior for the inequality operator, !=.
__lt__(self, other)
Defines behavior for the less-than operator, <.
__gt__(self, other)
Defines behavior for the greater-than operator, >.
__le__(self, other)
Defines behavior for the less-than-or-equal-to operator, <=.
__ge__(self, other)
Defines behavior for the greater-than-or-equal-to operator, >=.
class Version:
    def __init__(self, major, minor, patch):
        self.major = major
        self.minor = minor
        self.patch = patch

    def __str__(self):
        return f"{self.major}.{self.minor}.{self.patch}"

    def __eq__(self, other):
        if not isinstance(other, Version):
            return NotImplemented
        return (self.major, self.minor, self.patch) == (other.major, other.minor, other.patch)

    def __ne__(self, other):
        return not self == other

    def __lt__(self, other):
        if not isinstance(other, Version):
            return NotImplemented
        return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)

    def __le__(self, other):
        return self < other or self == other

    def __gt__(self, other):
        return not (self <= other)

    def __ge__(self, other):
        return not (self < other)


# Usage
v1 = Version(3, 8, 5)
v2 = Version(3, 9, 0)

print(v1 == v2)   # False
print(v1 < v2)    # True
print(v1 <= v2)   # True
print(v1 > v2)    # False
print(v1 >= v2)   # False
print(v1 != v2)   # True

Normal arithmetic operators

Now, we cover the typical binary operators (and a function or two): +, -, * and the like. These are, for the most part, pretty self-explanatory.

__add__(self, other)
Implements addition.
__sub__(self, other)
Implements subtraction.
__mul__(self, other)
Implements multiplication.
__floordiv__(self, other)
Implements integer division using the // operator.
__div__(self, other)
Implements division using the / operator.
__truediv__(self, other)
Implements true division. Note that this only works when from __future__ import division is in effect.
__mod__(self, other)
Implements modulo using the % operator.
__divmod__(self, other)
Implements behavior for long division using the divmod() built in function.
__pow__
Implements behavior for exponents using the ** operator.
__lshift__(self, other)
Implements left bitwise shift using the << operator.
__rshift__(self, other)
Implements right bitwise shift using the >> operator.
__and__(self, other)
Implements bitwise and using the & operator.
__or__(self, other)
Implements bitwise or using the | operator.
__xor__(self, other)
Implements bitwise xor using the ^ operator.
class Number:
    def __init__(self, value):
        self.value = value

    def __str__(self):
        return str(self.value)

    # Arithmetic operators
    def __add__(self, other):
        return Number(self.value + other.value)

    def __sub__(self, other):
        return Number(self.value - other.value)

    def __mul__(self, other):
        return Number(self.value * other.value)

    def __truediv__(self, other):
        return Number(self.value / other.value)

    def __floordiv__(self, other):
        return Number(self.value // other.value)

    def __mod__(self, other):
        return Number(self.value % other.value)

    def __divmod__(self, other):
        return divmod(self.value, other.value)

    def __pow__(self, other):
        return Number(self.value ** other.value)

    # Bitwise operators
    def __lshift__(self, other):
        return Number(self.value << other.value)

    def __rshift__(self, other):
        return Number(self.value >> other.value)

    def __and__(self, other):
        return Number(self.value & other.value)

    def __or__(self, other):
        return Number(self.value | other.value)

    def __xor__(self, other):
        return Number(self.value ^ other.value)


# Usage
a = Number(10)
b = Number(3)

print(a + b)        # 13
print(a - b)        # 7
print(a * b)        # 30
print(a / b)        # 3.333...
print(a // b)       # 3
print(a % b)        # 1
print(divmod(a, b)) # (3, 1)
print(a ** b)       # 1000
print(a << b)       # 80
print(a >> b)       # 1
print(a & b)        # 2
print(a | b)        # 11
print(a ^ b)        # 9

Type conversion magic methods

Python also has an array of magic methods designed to implement behavior for built in type conversion functions like float(). Here they are:

__int__(self)
Implements type conversion to int.
__long__(self)
Implements type conversion to long.
__float__(self)
Implements type conversion to float.
__complex__(self)
Implements type conversion to complex.
__oct__(self)
Implements type conversion to octal.
__hex__(self)
Implements type conversion to hexadecimal.
__index__(self)
Implements type conversion to an int when the object is used in a slice expression. If you define a custom numeric type that might be used in slicing, you should define __index__.
__trunc__(self)
Called when math.trunc(self) is called. __trunc__ should return the value of `self truncated to an integral type (usually a long).
import math

class Value:
    def __init__(self, num):
        self.num = num

    def __str__(self):
        return str(self.num)

    # Type conversion methods
    def __int__(self):
        return int(self.num)

    def __float__(self):
        return float(self.num)

    def __complex__(self):
        return complex(self.num)

    def __index__(self):
        return int(self.num)          # Used for slicing and base conversions

    def __trunc__(self):
        return math.trunc(self.num)

    def __oct__(self):
        return oct(int(self.num))

    def __hex__(self):
        return hex(int(self.num))


# Usage
v = Value(42.7)

print(int(v))           # 42
print(float(v))         # 42.7
print(complex(v))       # (42.7+0j)

print(hex(v))           # 0x2a
print(oct(v))           # 0o52
print(bin(v))           # 0b101010   (uses __index__)

print(math.trunc(v))    # 42

# Used in slicing
lst = ['a', 'b', 'c', 'd', 'e']
print(lst[v])           # 'e'  (index 42 % len? Wait, actually uses __index__)

Augmented assignment

Python also has a wide variety of magic methods to allow custom behavior to be defined for augmented assignment. You're probably already familiar with augmented assignment, it combines "normal" operators with assignment. If you still don't know what I'm talking about, here's an example:

x = 5
x += 1 # in other words x = x + 1
Each of these methods should return the value that the variable on the left hand side should be assigned to (for instance, for a += b, __iadd__ might return a + b, which would be assigned to a). Here's the list:

__iadd__(self, other)
Implements addition with assignment.
__isub__(self, other)
Implements subtraction with assignment.
__imul__(self, other)
Implements multiplication with assignment.
__ifloordiv__(self, other)
Implements integer division with assignment using the //= operator.
__idiv__(self, other)
Implements division with assignment using the /= operator.
__itruediv__(self, other)
Implements true division with assignment. Note that this only works when from __future__ import division is in effect.
__imod__(self, other)
Implements modulo with assignment using the %= operator.
__ipow__
Implements behavior for exponents with assignment using the **= operator.
__ilshift__(self, other)
Implements left bitwise shift with assignment using the <<= operator.
__irshift__(self, other)
Implements right bitwise shift with assignment using the >>= operator.
__iand__(self, other)
Implements bitwise and with assignment using the &= operator.
__ior__(self, other)
Implements bitwise or with assignment using the |= operator.
__ixor__(self, other)
Implements bitwise xor with assignment using the ^= operator.
class Number:
    def __init__(self, value):
        self.value = value

    def __str__(self):
        return str(self.value)

    # Augmented assignment methods (in-place)
    def __iadd__(self, other):
        self.value += other.value
        return self

    def __isub__(self, other):
        self.value -= other.value
        return self

    def __imul__(self, other):
        self.value *= other.value
        return self

    def __itruediv__(self, other):
        self.value /= other.value
        return self

    def __ifloordiv__(self, other):
        self.value //= other.value
        return self

    def __imod__(self, other):
        self.value %= other.value
        return self

    def __ipow__(self, other):
        self.value **= other.value
        return self

    def __ilshift__(self, other):
        self.value <<= other.value
        return self

    def __irshift__(self, other):
        self.value >>= other.value
        return self

    def __iand__(self, other):
        self.value &= other.value
        return self

    def __ior__(self, other):
        self.value |= other.value
        return self

    def __ixor__(self, other):
        self.value ^= other.value
        return self


# Usage
n = Number(10)
print("Original:", n)

n += Number(5)      # 15
print("After += 5:", n)

n -= Number(3)      # 12
print("After -= 3:", n)

n *= Number(2)      # 24
print("After *= 2:", n)

n //= Number(5)     # 4
print("After //= 5:", n)

n **= Number(3)     # 64
print("After **= 3:", n)

n <<= Number(1)     # 128
print("After <<= 1:", n)

Unary operators and functions

Unary operators and functions only have one operand, e.g. negation, absolute value, etc.

__pos__(self)
Implements behavior for unary positive (e.g. +some_object)
__neg__(self)
Implements behavior for negation (e.g. -some_object)
__abs__(self)
Implements behavior for the built in abs() function.
__invert__(self)
Implements behavior for inversion using the ~ operator. For an explanation on what this does, see the Wikipedia article on bitwise operations.
__round__(self, n)
Implements behavior for the built in round() function. n is the number of decimal places to round to.
__floor__(self)
Implements behavior for math.floor(), i.e., rounding down to the nearest integer.
__ceil__(self)
Implements behavior for math.ceil(), i.e., rounding up to the nearest integer.
__trunc__(self)
Implements behavior for math.trunc(), i.e., truncating to an integral.
import math

class Number:
    def __init__(self, value):
        self.value = float(value)   # store as float for flexibility

    def __str__(self):
        return str(self.value)

    # Unary operators
    def __pos__(self):
        return Number(+self.value)          # rarely used, but good to define

    def __neg__(self):
        return Number(-self.value)

    def __abs__(self):
        return Number(abs(self.value))

    def __invert__(self):
        return Number(~int(self.value))     # bitwise NOT (works on integers)

    def __round__(self, ndigits=None):
        return Number(round(self.value, ndigits))

    def __floor__(self):
        return Number(math.floor(self.value))

    def __ceil__(self):
        return Number(math.ceil(self.value))

    def __trunc__(self):
        return Number(math.trunc(self.value))


# Usage
n = Number(-42.7)

print("+n   →", +n)           # Unary positive
print("-n   →", -n)           # Negation
print("abs(n) →", abs(n))     # Absolute value
print("~n   →", ~n)           # Bitwise NOT (on int part)

print("round(n)     →", round(n))           # -43
print("round(n, 1)  →", round(n, 1))        # -42.7
print("math.floor(n)→", math.floor(n))      # -43
print("math.ceil(n) →", math.ceil(n))       # -42
print("math.trunc(n)→", math.trunc(n))      # -42

The magic behind containers

Without any more wait, here are the magic methods that containers use:

__bool__(self)

__len__(self)
Returns the length of the container. Part of the protocol for both immutable and mutable containers.
__getitem__(self, key)
Defines behavior for when an item is accessed, using the notation self[key]. This is also part of both the mutable and immutable container protocols. It should also raise appropriate exceptions: TypeError if the type of the key is wrong and KeyError if there is no corresponding value for the key.
__setitem__(self, key, value)
Defines behavior for when an item is assigned to, using the notation self[nkey] = value. This is part of the mutable container protocol. Again, you should raise KeyError and TypeError where appropriate.
__delitem__(self, key)
Defines behavior for when an item is deleted (e.g. del self[key]). This is only part of the mutable container protocol. You must raise the appropriate exceptions when an invalid key is used.
__iter__(self) and __next__(self)
Should return an iterator for the container. Iterators are returned in a number of contexts, most notably by the iter() built in function and when a container is looped over using the form for x in container:. Iterators are their own objects, and they also must define an __iter__ method that returns self.
__reversed__(self)
Called to implement behavior for the reversed() built in function. Should return a reversed version of the sequence. Implement this only if the sequence class is ordered, like list or tuple.
__contains__(self, item)
__contains__ defines behavior for membership tests using in and not in. Why isn't this part of a sequence protocol, you ask? Because when __contains__ isn't defined, Python just iterates over the sequence and returns True if it comes across the item it's looking for.
__missing__(self, key)
__missing__ is used in subclasses of dict. It defines behavior for whenever a key is accessed that does not exist in a dictionary (so, for instance, if I had a dictionary d and said d["george"] when "george" is not a key in the dict, d.__missing__("george") would be called).
class CustomList:
    def __init__(self, items=None):
        self._items = list(items) if items is not None else []

    def __str__(self):
        return str(self._items)

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

    def __bool__(self):
        return len(self) > 0

    def __getitem__(self, key):
        if isinstance(key, slice):
            return CustomList(self._items[key])
        return self._items[key]

    def __setitem__(self, key, value):
        self._items[key] = value

    def __delitem__(self, key):
        del self._items[key]

    def __iter__(self):
        return iter(self._items)          # Simple delegation

    def __reversed__(self):
        return reversed(self._items)

    def __contains__(self, item):
        return item in self._items

    # Bonus: A dict-like example for __missing__
    class SafeDict(dict):
        def __missing__(self, key):
            return f"<Missing: {key}>"


# Usage
fruits = CustomList(["apple", "banana", "cherry", "date"])

print(len(fruits))                    # 4
print(bool(fruits))                   # True
print(fruits[1])                      # banana
print(fruits[1:3])                    # CustomList(['banana', 'cherry'])

fruits[2] = "coconut"
del fruits[0]
print(fruits)                         # ['banana', 'coconut', 'date']

print("coconut" in fruits)            # True
print("grape" in fruits)              # False

# Iteration
for fruit in fruits:
    print(fruit)

# Reversed
print(list(reversed(fruits)))         # ['date', 'coconut', 'banana']

# Empty check
empty = CustomList()
print(bool(empty))                    # False

# __missing__ example
d = CustomList.SafeDict(name="Alice")
print(d["name"])      # Alice
print(d["age"])       # <Missing: age>

Magic methods are one of Python’s most powerful features. They allow you to turn your custom classes into first-class citizens that behave just like built-in types.

Here’s what we covered:

Comparison operators** (`==`, `<`, `>`, etc.)
Arithmetic & bitwise operators** (`+`, `-`, `*`, `/`, `&`, `|`, etc.)
Augmented assignment** (`+=`, `*=` etc.)
Unary operators** (`-obj`, `abs()`, `~`)
Type conversions** (`int()`, `float()`, `round()`)
Container protocol** (`len()`, indexing, iteration, `in`, slicing)

By thoughtfully implementing these dunder methods, you can create intuitive, readable, and Pythonic APIs, whether you’re building domain-specific classes, data structures, or full libraries.

Final Tip:
Don’t overuse magic methods. Use them when they make your code clearer and more expressive, not just because you can.

Classes Magic Methods OOP Python

← All training