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 < 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 < 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 > 0
def __lt__(self, other):
if isinstance(other, (int, float)):
return self.value < 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.