Magic methods (also called dunder methods, short for "double underscore") are specially named methods like __init__, __repr__, and __add__ that Python calls automatically when you use built-in syntax on an object. When you write a + b, len(obj), obj[key], str(obj), or with obj:, Python translates each into a call to a corresponding dunder method. They matter because they let your own classes behave like built-in types — supporting operators, iteration, indexing, and printing — so your objects feel native instead of forcing callers into awkward, non-standard method names.
This is a practical, Python 3.12+ reference organised by category. Each section explains what the methods do and shows idiomatic usage, and the quick-reference table near the end maps every common syntax to its dunder. If you are still getting comfortable with classes, start with our primer on object-oriented programming with Python and the deep dive on self and __init__.
Object construction: __new__, __init__, __del__
Instance creation happens in two steps. __new__(cls, ...) is a static method that allocates and returns the new instance — it runs first and is responsible for actually creating the object. __init__(self, ...) then initialises the already-created instance by setting attributes; it must return None. You override __new__ only for special cases (immutable types, singletons, subclassing int/str/tuple); for everyday classes, just write __init__.
__del__(self) is the finaliser, called when an object is about to be garbage-collected. It is not a reliable destructor — its timing is undefined and it may never run at interpreter shutdown — so use context managers (__enter__/__exit__, covered below) or try/finally for cleanup, not __del__.
class Address:
def __new__(cls, *args, **kwargs):
print("__new__ creates the instance")
return super().__new__(cls) # allocate via object.__new__
def __init__(self, city, pin):
print("__init__ initialises it")
self.city = city
self.pin = pin
>>> a = Address("Hyderabad", "500082")
__new__ creates the instance
__init__ initialises it
>>> a.city
'Hyderabad'
# Calling __new__ directly returns an *uninitialised* object:
>>> raw = Address.__new__(Address)
__new__ creates the instance
>>> raw.city
Traceback (most recent call last):
...
AttributeError: 'Address' object has no attribute 'city'String representation: __repr__, __str__, __format__
These three control how an object is rendered as text.
__repr__(self)should return an unambiguous, developer-facing string — ideally valid Python that could recreate the object (e.g.Vector(1, 2)). It is what you see in the REPL and whatrepr()returns, and it is the fallback forstr()when__str__is absent. Always define__repr__.__str__(self)returns a readable, user-facing string used bystr()andprint(). Define it when the friendly form differs from the developer form.__format__(self, format_spec)powersformat(obj, spec)and f-string format specifiers likef"{obj:.2f}". The default delegates to__str__.
A reliable rule: if you only implement one, make it __repr__.
Comparison and ordering: __eq__, __lt__, and friends
Rich comparison methods back the comparison operators: __eq__ (==), __ne__ (!=), __lt__ (<), __le__ (<=), __gt__ (>), __ge__ (>=). Each takes (self, other) and should return NotImplemented (the singleton, not the NotImplementedError exception) when it does not know how to compare against other. Returning NotImplemented lets Python try the reflected operation on other, which is how cross-type comparisons cooperate.
Since Python 3, defining __eq__ is enough to get __ne__ for free (Python negates it automatically). To get all four ordering operators without writing each one, define __eq__ plus a single ordering method (usually __lt__) and decorate the class with functools.total_ordering.
from functools import total_ordering
@total_ordering
class Version:
def __init__(self, major, minor):
self.major = major
self.minor = minor
def _key(self):
return (self.major, self.minor)
def __eq__(self, other):
if not isinstance(other, Version):
return NotImplemented
return self._key() == other._key()
def __lt__(self, other):
if not isinstance(other, Version):
return NotImplemented
return self._key() < other._key()
>>> Version(3, 12) > Version(3, 9) # __gt__ derived by total_ordering
True
>>> Version(3, 12) == Version(3, 12)
TrueHashing: __hash__
__hash__(self) returns an integer used to place an object in sets and dict keys. The contract is strict: objects that compare equal must have the same hash, and a hashable object's hash must never change during its lifetime. Practically, base the hash on the same immutable fields you use in __eq__, typically via hash((self.x, self.y)).
A subtle gotcha: when you define __eq__, Python sets __hash__ to None, making instances unhashable (you cannot put them in a set or use them as dict keys) unless you also define __hash__. This is deliberate — mutable objects that define equality usually should not be hashable. Define __hash__ explicitly only for immutable value objects.
Numeric and operator overloading
Arithmetic operators map to dunder methods: __add__ (+), __sub__ (-), __mul__ (*), __truediv__ (/), __floordiv__ (//), __mod__ (%), __pow__ (**), plus the bitwise set __and__, __or__, __xor__, __lshift__, __rshift__. Each has a reflected form (__radd__, __rmul__, ...) that Python tries when the left operand does not implement the operation — this is what makes 3 * vector work even though int knows nothing about your class. There are also in-place forms (__iadd__, __imul__, ...) for +=, -=, etc., and unary forms __neg__ (-x), __pos__ (+x), __abs__ (abs()), __invert__ (~x).
Note __div__ from Python 2 no longer exists — true division is always __truediv__ in Python 3. Numeric conversions use __int__, __float__, __complex__, __bool__, and __index__ (for genuine integer use such as slicing).
Container and sequence protocols
These make your object behave like a list, dict, or set:
__len__(self)backslen(obj).__getitem__(self, key)/__setitem__(self, key, value)/__delitem__(self, key)backobj[key],obj[key] = v, anddel obj[key].keymay be anint, aslice, or any object (for dict-like classes).__contains__(self, item)backsitem in obj. If absent, Python falls back to iteration.__iter__(self)returns an iterator and backsfor x in obj;__next__(self)on that iterator yields the next value and raisesStopIterationwhen exhausted. (For generators, see our guide toyieldand generators.)__reversed__(self)backsreversed(obj).
Defining __getitem__ alone gives you basic iteration and in support for free, but __iter__ is preferred for anything more than a simple integer-indexed sequence.
Callable objects: __call__
__call__(self, ...) makes an instance callable like a function — writing obj(args) runs type(obj).__call__(obj, args). This is ideal for stateful function-like objects (configurable validators, accumulators, decorators implemented as classes, and partial-application helpers) where a plain function plus a closure would be clumsy.
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, value):
return value * self.factor
>>> double = Multiplier(2)
>>> double(21) # instance called like a function
42
>>> callable(double)
TrueContext managers: __enter__ and __exit__
The with statement is backed by two methods. __enter__(self) runs on entry and its return value is bound to the as target. __exit__(self, exc_type, exc_value, traceback) runs on exit — always, whether the block succeeds or raises — making it the right place for cleanup (closing files, releasing locks, rolling back transactions). The three arguments describe any exception that occurred (all None on success). Return a truthy value from __exit__ to suppress the exception; return None/falsy to let it propagate. For simple cases, contextlib.contextmanager lets you write a context manager as a generator instead.
class Timer:
def __enter__(self):
import time
self.start = time.perf_counter()
return self # bound to the `as` target
def __exit__(self, exc_type, exc_value, traceback):
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.0123sAttribute access: __getattr__, __getattribute__, __setattr__, __delattr__
These intercept attribute lookups and assignments:
__getattribute__(self, name)is called for every attribute access — override it only with great care, and always delegate tosuper().__getattribute__(name)to avoid infinite recursion.__getattr__(self, name)is called only as a fallback, when normal lookup fails (the attribute is not found). This is the safe hook for lazy attributes, proxies, and dynamic dispatch.__setattr__(self, name, value)intercepts every assignmentobj.name = value; set the value viasuper().__setattr__(name, value)orself.__dict__[name] = valueto avoid recursion.__delattr__(self, name)interceptsdel obj.name.
Classes can also restrict attributes with __slots__, which removes the per-instance __dict__ to save memory and prevent typo-created attributes.
A note on descriptors
Descriptors are objects that define __get__, __set__, or __delete__ and live as class attributes, customising what happens when that attribute is read or written on instances. They are the machinery behind property, classmethod, staticmethod, and functools.cached_property. Most of the time you will reach for the built-in property rather than write a descriptor by hand, but understanding the protocol explains how those built-ins work — see our dedicated guide to Python descriptors.
Putting it together: a Vector value object
The example below combines several categories into one cohesive class — construction, representation, equality, hashing, operator overloading (with a reflected __rmul__), the container protocol, and __bool__. This is what idiomatic dunder usage looks like in practice: each method makes the object behave more like a built-in type.
import math
class Vector:
def __init__(self, *components):
self.components = tuple(float(c) for c in components)
# --- representation ---
def __repr__(self):
inner = ", ".join(repr(c) for c in self.components)
return f"Vector({inner})"
def __str__(self):
return f"<{', '.join(str(c) for c in self.components)}>"
# --- equality + hashing (immutable value object) ---
def __eq__(self, other):
if not isinstance(other, Vector):
return NotImplemented
return self.components == other.components
def __hash__(self):
return hash(self.components)
# --- operator overloading ---
def __add__(self, other):
if not isinstance(other, Vector):
return NotImplemented
return Vector(*(a + b for a, b in zip(self.components, other.components)))
def __mul__(self, scalar):
return Vector(*(c * scalar for c in self.components))
__rmul__ = __mul__ # makes 3 * v work, not just v * 3
def __abs__(self):
return math.hypot(*self.components)
# --- container protocol ---
def __len__(self):
return len(self.components)
def __getitem__(self, index):
return self.components[index]
def __iter__(self):
return iter(self.components)
# --- truthiness ---
def __bool__(self):
return any(self.components)
>>> v1 = Vector(1, 2)
>>> v2 = Vector(10, 13)
>>> v1 + v2
Vector(11.0, 15.0)
>>> 3 * v1 # reflected __rmul__
Vector(3.0, 6.0)
>>> abs(Vector(3, 4)) # __abs__
5.0
>>> len(v1), v1[0] # __len__, __getitem__
(2, 1.0)
>>> list(v1) # __iter__
[1.0, 2.0]
>>> bool(Vector(0, 0)) # __bool__
False
>>> {v1, v2} # hashable thanks to __hash__
{Vector(1.0, 2.0), Vector(10.0, 13.0)}Quick-reference table
| Syntax / built-in | Magic method | Category |
|---|---|---|
Cls(...) (allocate) |
__new__(cls, ...) |
construction |
Cls(...) (initialise) |
__init__(self, ...) |
construction |
| garbage-collected | __del__(self) |
construction |
repr(obj), REPL echo |
__repr__(self) |
representation |
str(obj), print(obj) |
__str__(self) |
representation |
format(obj, spec), f-strings |
__format__(self, spec) |
representation |
a == b / a != b |
__eq__ / __ne__ |
comparison |
a < b, <=, >, >= |
__lt__, __le__, __gt__, __ge__ |
comparison |
hash(obj), set/dict key |
__hash__(self) |
hashing |
a + b, a - b, a * b |
__add__, __sub__, __mul__ |
numeric |
a / b, a // b, a % b, a ** b |
__truediv__, __floordiv__, __mod__, __pow__ |
numeric |
reflected (b is left) |
__radd__, __rmul__, ... |
numeric |
in-place a += b |
__iadd__, __imul__, ... |
numeric |
-x, +x, abs(x), ~x |
__neg__, __pos__, __abs__, __invert__ |
numeric |
int(x), float(x), bool(x) |
__int__, __float__, __bool__ |
conversion |
len(obj) |
__len__(self) |
container |
obj[k] / obj[k] = v / del obj[k] |
__getitem__ / __setitem__ / __delitem__ |
container |
item in obj |
__contains__(self, item) |
container |
for x in obj / next(it) |
__iter__ / __next__ |
container |
reversed(obj) |
__reversed__(self) |
container |
obj(...) |
__call__(self, ...) |
callable |
with obj: |
__enter__ / __exit__ |
context manager |
obj.x (fallback) |
__getattr__(self, name) |
attribute |
obj.x (always) |
__getattribute__(self, name) |
attribute |
obj.x = v / del obj.x |
__setattr__ / __delattr__ |
attribute |
| descriptor read/write | __get__ / __set__ / __delete__ |
descriptor |
The authoritative list lives in the Python data model documentation, which covers every special method for the current language version.
At MicroPyramid we have built production Python systems for over a decade, and clean use of magic methods — value objects, custom containers, and well-behaved context managers — is part of how we keep large Python codebases readable and maintainable. If you want a robust, idiomatic API for your domain, the data model is one of the most powerful tools Python gives you.
Frequently Asked Questions
What are magic methods (dunder methods) in Python?
Magic methods are specially named methods wrapped in double underscores — such as __init__, __repr__, __add__, and __len__ — that Python invokes automatically in response to built-in syntax and operations. When you use +, len(), indexing, print(), or a with block on an object, the interpreter dispatches to the matching dunder method. They let your classes integrate seamlessly with Python's operators and built-in functions.
What is the difference between __str__ and __repr__?
__repr__ returns an unambiguous, developer-facing string (ideally one that could recreate the object, like Vector(1, 2)) and is what the REPL and repr() show; it is also the fallback when __str__ is missing. __str__ returns a friendly, user-facing string used by str() and print(). If you implement only one, implement __repr__.
When should I use __new__ instead of __init__?
Use __init__ for virtually all normal classes — it initialises an already-created instance. Reach for __new__ only when you must control object creation itself: subclassing immutable built-ins like int, str, or tuple; implementing singletons or instance caching; or returning an object of a different type. __new__ allocates and returns the instance; __init__ then configures it.
Why do my objects become unhashable when I define __eq__?
Defining __eq__ causes Python to set __hash__ to None, which makes instances unhashable so you cannot use them in a set or as dict keys. This protects you from the broken contract of mutable objects that compare equal but could change their hash. If your object is an immutable value object, define __hash__ explicitly, basing it on the same fields as __eq__ (e.g. hash((self.x, self.y))).
What is functools.total_ordering for?
functools.total_ordering is a class decorator that fills in the missing rich-comparison operators for you. Define __eq__ plus one ordering method (typically __lt__), apply the decorator, and Python derives __le__, __gt__, and __ge__ automatically. It reduces boilerplate, though hand-written comparisons can be marginally faster in hot paths.
How do __enter__ and __exit__ make a context manager?
The with statement calls __enter__ on entry (binding its return value to the as target) and __exit__ on exit — always, even if the block raises. __exit__(self, exc_type, exc_value, traceback) receives details of any exception (all None on success); return a truthy value to suppress it, or a falsy value to let it propagate. This guarantees cleanup such as closing files or releasing locks, which is why context managers are preferred over the unreliable __del__.