Magic methods (also called dunder methods, short for "double underscore") are special Python methods that begin and end with two underscores. They let your classes behave like native Python types, responding to operators, function calls, iteration, indexing, and much more. Mastering these methods is what separates an intermediate Python developer from a true language expert.

In this comprehensive guide, you'll learn the most important magic methods in Python, from object creation fundamentals to advanced customization techniques. Each section includes practical examples you can use immediately in your projects.

What Are Magic Methods?

Magic methods are Python's implementation of what other languages call operator overloading and polymorphism. When you write obj + other_obj or len(collection), Python internally looks for specific methods on those objects' classes. The interpreter converts common operations into predictable method calls, all defined in the official Python documentation on special methods.

For instance, when you use +, Python calls __add__. When you use len(), it calls __len__. This means you can make your own classes respond to these same operations simply by implementing the appropriate methods.

Object Creation and Destruction Methods

__new__ and __init__

__new__ is Python's true constructor — it creates and returns a new instance of the class. __init__ is the initializer, responsible for setting up the newly created object's state. While __init__ is widely used, __new__ is more common in patterns like Singleton or when working with immutable classes.

class Singleton:
    _instance = None
def __new__(cls, *args, **kwargs):
    if cls._instance is None:
        cls._instance = super().__new__(cls)
    return cls._instance

def __init__(self, value):
    self.value = value

a = Singleton(10) b = Singleton(20) print(a is b) # True print(a.value) # 20

__del__

The __del__ method is called when the object is about to be destroyed by the garbage collector. Use it with caution, as there is no guarantee when it will execute. It is useful for releasing external resources like files or connections, but explicit management with context managers (see __enter__ and __exit__) is almost always preferable.

String Representation

__str__ and __repr__

These are probably the most well-known and widely used magic methods. __repr__ should return an "official" and unambiguous representation of the object, ideally a string that recreates the object. __str__ returns an "informal" human-readable representation for end users. If __str__ is not implemented, Python falls back to __repr__.

class User:
    def __init__(self, name, email):
        self.name = name
        self.email = email
def __repr__(self):
    return f"User(name='{self.name}', email='{self.email}')"

def __str__(self):
    return f"{self.name} <{self.email}>"

user = User("Maria Silva", "[email protected]") print(repr(user)) # User(name='Maria Silva', email='[email protected]') print(str(user)) # Maria Silva <[email protected]>

Real Python has an excellent guide on magic methods that dives even deeper into these concepts.

__format__

The __format__ method allows you to customize how your object behaves inside f-strings and with the format() function. You can create custom format specifications:

class Currency:
    symbols = {"BRL": "R$", "USD": "$", "EUR": "€"}
def __init__(self, value, currency="USD"):
    self.value = value
    self.currency = currency

def __format__(self, spec):
    sym = self.symbols.get(self.currency, self.currency)
    if spec == "verbose":
        return f"{sym} {self.value:,.2f} ({self.currency})"
    return f"{sym} {self.value:{spec}f}" if spec else f"{sym} {self.value:,.2f}"

money = Currency(1250.50) print(f"{money}") # $ 1,250.50 print(f"{money:verbose}") # $ 1,250.50 (USD)

Comparison Methods

Comparison methods allow objects to be compared with operators like ==, <, >, <=, >=, and !=. Implementing them correctly also enables sorting with sorted().

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age
def __eq__(self, other):
    if not isinstance(other, Person):
        return NotImplemented
    return self.name == other.name and self.age == other.age

def __lt__(self, other):
    if not isinstance(other, Person):
        return NotImplemented
    return self.age &lt; other.age

def __hash__(self):
    return hash((self.name, self.age))

def __repr__(self):
    return f"{self.name} ({self.age})"

people = [ Person("Alice", 30), Person("Bob", 25), Person("Charlie", 35), ] for p in sorted(people): print(p) # Bob (25), Alice (30), Charlie (35)

Implementing __eq__ and __hash__ together is essential if you intend to use your objects in sets or as dictionary keys. GeeksforGeeks has a complete reference on dunder methods with more examples of these patterns.

Arithmetic Methods

Python lets you overload virtually every arithmetic operator. This is especially useful for creating custom numeric types, vectors, matrices, and domain-specific objects.

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
def __add__(self, other):
    if not isinstance(other, Vector):
        return NotImplemented
    return Vector(self.x + other.x, self.y + other.y)

def __sub__(self, other):
    if not isinstance(other, Vector):
        return NotImplemented
    return Vector(self.x - other.x, self.y - other.y)

def __mul__(self, scalar):
    if not isinstance(scalar, (int, float)):
        return NotImplemented
    return Vector(self.x * scalar, self.y * scalar)

def __abs__(self):
    return (self.x ** 2 + self.y ** 2) ** 0.5

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

v1 = Vector(3, 4) v2 = Vector(1, 2) print(v1 + v2) # Vector(4, 6) print(v1 * 2) # Vector(6, 8) print(abs(v1)) # 5.0

Beyond basic operators, there are methods for augmented assignment operators like __iadd__ (+=), __isub__ (-=), unary operators like __neg__ and __pos__, and reflected operators. Check the Python operator module documentation for the full list.

Container Methods

These methods let your classes behave like lists, dictionaries, or sets. They form the backbone of Python's container protocol.

class CustomList:
    def __init__(self, *items):
        self._items = list(items)
def __len__(self):
    return len(self._items)

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

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

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

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

def __iter__(self):
    return iter(self._items)

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

def __repr__(self):
    return f"CustomList({self._items!r})"

lst = CustomList(10, 20, 30, 40, 50) print(len(lst)) # 5 print(30 in lst) # True print(lst[1:3]) # [20, 30] lst[0] = 100 print(lst) # CustomList([100, 20, 30, 40, 50])

Implementing the full container protocol makes your class work seamlessly with for loops, list comprehensions, and most built-in functions that expect sequences.

Callable Objects with __call__

The __call__ method lets instances of a class be called like functions. This is extremely useful for creating stateful functions (classes that act as closures), class-based decorators, and factories.

from time import perf_counter

class Timer: def init(self): self.times = []

def __call__(self, func):
    import functools
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = perf_counter()
        result = func(*args, **kwargs)
        end = perf_counter()
        self.times.append((func.__name__, end - start))
        print(f"{func.__name__} ran in {end - start:.4f}s")
        return result
    return wrapper

timer = Timer()

@timer def process_data(): return sum(range(1_000_000))

@timer def another_function(): return sum(i * 2 for i in range(500_000))

process_data() another_function() print(timer.times)

Callable objects are an elegant alternative to functions in many scenarios, especially when you need to maintain state between calls.

Context Managers: __enter__ and __exit__

Context managers enable the with statement syntax for resource management. Implementing __enter__ and __exit__ in your class lets you create custom context managers for database connections, files, transactions, and more.

class DatabaseConnection:
    def __init__(self, connection_string):
        self.connection_string = connection_string
        self.connection = None
def __enter__(self):
    print(f"Connecting to {self.connection_string}...")
    self.connection = {"connected": True, "url": self.connection_string}
    return self.connection

def __exit__(self, exc_type, exc_val, exc_tb):
    if self.connection:
        print("Closing connection...")
        self.connection["connected"] = False
    if exc_type:
        print(f"Handled error: {exc_val}")
    return True  # Suppress exceptions

with DatabaseConnection("postgresql://localhost:5432/mydb") as conn: print("Running database operations...") raise ValueError("Something went wrong!")

print("Continued after handled exception")

Returning True from __exit__ suppresses exceptions. If you return False or None, the exception propagates. This is one of the most important features for writing robust, idiomatic Python code.

Attribute Access Control

The __getattr__, __setattr__, __delattr__, and __getattribute__ methods provide fine-grained control over how attributes are accessed and modified. Use __getattr__ to provide default values for missing attributes and __setattr__ to validate or transform values before storing them.

class Validated:
    def __init__(self):
        self.__dict__["_data"] = {}
def __getattr__(self, name):
    if name in self._data:
        return self._data[name]
    raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")

def __setattr__(self, name, value):
    if name.startswith("_"):
        self.__dict__[name] = value
    elif isinstance(value, (int, float)) and value &lt; 0:
        raise ValueError(f"{name} cannot be negative")
    else:
        self._data[name] = value

obj = Validated() obj.name = "Python" obj.age = 25 print(obj.name) # Python

obj.age = -5 # ValueError: age cannot be negative

Be careful with infinite loops when using __setattr__ — use self.__dict__ directly to avoid recursion. The official documentation on attribute access details all the nuances of this mechanism.

Type Conversion: __int__, __float__, __bool__

These methods let your objects be converted to native types using functions like int(), float(), and bool(). The __bool__ method is particularly important, as it determines how the object behaves in boolean contexts like if, while, and logical operators.

class Score:
    def __init__(self, value, maximum=100):
        self.value = value
        self.maximum = maximum
def __int__(self):
    return int(self.value)

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

def __bool__(self):
    return self.value &gt; 0

def __lt__(self, other):
    if isinstance(other, (int, float)):
        return self.value &lt; other
    return NotImplemented

s = Score(85) print(int(s)) # 85 print(float(s)) # 85.0 print(bool(s)) # True print(s > 50) # True

__slots__: Memory Optimization

The special attribute __slots__ is not exactly a magic method, but it is a powerful mechanism for saving memory. By declaring __slots__, you prevent the creation of the __dict__ dictionary on each instance, significantly reducing memory consumption for objects that exist in large numbers.

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

p = Point(10, 20) print(p.x, p.y) # 10 20

p.z = 30 # AttributeError: 'Point' object has no attribute 'z'

In applications that create millions of objects (such as data processing or gaming), using __slots__ can reduce memory usage by up to 50%, as stated in the official documentation on __slots__.

Data Classes vs Magic Methods

Since Python 3.7, dataclasses (documented in PEP 557) automate the implementation of several magic methods like __init__, __repr__, __eq__, and __hash__. For simple classes that are primarily data containers, dataclasses are often the better choice:

from dataclasses import dataclass

@dataclass class Product: name: str price: float quantity: int = 0

@property
def total_value(self) -> float:
    return self.price * self.quantity

p1 = Product("Laptop", 3500.00, 5) p2 = Product("Laptop", 3500.00, 5) print(p1) # Product(name='Laptop', price=3500.0, quantity=5) print(p1 == p2) # True (automatic eq)

For more complex cases with business logic, validation, or specific behavior, manually implementing magic methods is still the most flexible approach.

Best Practices and Common Pitfalls

1. Always return NotImplemented instead of raising TypeError: When a magic method doesn't know how to handle the received type, return NotImplemented. This allows Python to try the reverse operation on the other operand.

2. Implement __hash__ when you implement __eq__: Objects that implement __eq__ without __hash__ become unhashable and cannot be used in sets or as dictionary keys.

3. Keep __repr__ unambiguous and __str__ readable: The convention is clear: __repr__ for the developer, __str__ for the end user.

4. Watch out for side effects in __del__: Python's garbage collector does not guarantee when __del__ will be called. Use context managers for deterministic resource release.

5. Prefer @property decorators over __getattr__: For controlled access to specific attributes, @property is clearer and more predictable than __getattr__. Check the property function documentation for more details.

6. Avoid __slots__ prematurely: Only use __slots__ when you have a very large number of instances and need to optimize memory usage. Premature optimization adds unnecessary complexity.

Conclusion

Magic methods are one of Python's most powerful features. They allow you to create classes that integrate seamlessly with the language's syntax and protocols, resulting in cleaner, more expressive, and more idiomatic code.

Mastering __init__, __str__, __repr__, comparison methods, arithmetic operators, the container protocol, and context managers will transform the quality of your Python code. Start implementing these methods in your classes gradually — you will see the difference in readability and flexibility immediately.

To continue your Python studies, check out our complete guide on Object-Oriented Programming in Python and the Decorators in Python tutorial, which perfectly complement what you learned here about magic methods.

And remember: the official documentation is your best friend. Keep the Special Method Names page in the Python Documentation always at hand — it is the definitive reference on magic methods in Python.