Python Magic Methods: The Complete Dunder Guide

Ever wondered what happens when you type len(my_list) or a + b? You're not just calling a function, you're triggering a hidden gateway into Python's object system. Those double-underscore methods (dunders) are where the real magic lives, and understanding them transforms you from someone who writes Python code into someone who understands how Python works underneath.
Here's the thing: every operator, every built-in function, every bracket you write gets translated into a dunder method call. Python's syntax is just a convenient wrapper around these powerful methods. Once you see this layer, you'll start designing classes that feel native to Python, classes where + means exactly what you want it to mean, where len() works like it should, where objects behave like first-class citizens in the language.
Table of Contents
- Python's Data Model: The Foundation Everything Else Rests On
- Why Magic Methods Matter
- How Python Uses Dunders
- Object Lifecycle: Birth and Death
- `__new__` and `__init__`: The Creation Pipeline
- `__del__`: Cleanup Time
- String Representation: How Your Object Looks
- `__repr__` vs `__str__`
- `__format__`: Custom Formatting
- Comparison Operators: The Equality Problem
- The Basic Six
- `@total_ordering`: Write Less, Get More
- Operator Overloading Philosophy
- Arithmetic Operators: Making Math Feel Native
- The Core Arithmetic Dunders
- In-Place Operators
- Container Protocol: Acting Like a List
- `__len__`, `__getitem__`, `__setitem__`
- `__contains__`: The `in` Operator
- `__iter__` and `__next__`: The Iteration Protocol
- Context Manager Protocol
- Callable Objects: Making Instances Act Like Functions
- Attribute Access: The `__getattr__` Family
- `__getattr__`: The Fallback
- `__setattr__`: Intercept All Attribute Sets
- `__getattribute__`: Total Control
- Common Dunder Mistakes
- Putting It All Together: A Complete Example
- Summary: The Hidden Architecture
Python's Data Model: The Foundation Everything Else Rests On
Before we get into specific dunders, we need to talk about why they exist at all. Python's data model is the formal specification of how objects behave across the entire language. It's not a convention or a suggestion, it's the contract Python makes with every object you create. When Guido van Rossum designed Python, he made a deliberate choice: instead of hardcoding behavior for every possible type, Python would define a set of special methods that any object could implement to participate fully in the language's ecosystem.
Think about what that means in practice. A list, a string, a dictionary, a numpy array, a pandas DataFrame, they all play by the same rules. When you write for item in my_thing:, Python doesn't care whether my_thing is a built-in list or a custom class you wrote this morning. It just checks whether the object implements the iteration protocol through __iter__ and __next__. If it does, you get full iteration support. If it doesn't, you get a clear, helpful error. This uniformity is what makes Python feel so coherent, and it's why Python code from a beginner and code from a seasoned library author can mix so naturally.
The data model covers seven major areas: object creation and destruction, string representation, comparison operations, arithmetic operations, container behavior, attribute access, and callable behavior. Each area has its own set of dunder methods, and each method has a specific contract, a set of rules you agree to follow when you implement it. Break the contract and you get subtle bugs. Follow it faithfully and your custom class becomes indistinguishable from a built-in type. That's the goal, and that's why every serious Python developer needs to understand this layer deeply. Let's dig in.
Why Magic Methods Matter
Magic methods aren't optional flavor. They're the foundation of Python's design philosophy: make the common case beautiful. Here's what we're talking about:
Before we look at the translation layer, it helps to internalize that Python's interpreter is doing this swap every single time it encounters an operator or a built-in call. This isn't compilation magic or JIT optimization, it's the runtime itself, constantly delegating to your methods.
# You write this (syntactic sugar)
result = obj1 + obj2
length = len(my_container)
if obj1 == obj2:
pass
# Python translates to this (the reality)
result = obj1.__add__(obj2)
length = my_container.__len__()
if obj1.__eq__(obj2):
passWhy it matters: When you define magic methods, your custom objects integrate seamlessly with Python idioms. You don't need wrapper functions or special methods, you overload the operators themselves.
The hidden layer here is readability through idiom. A developer seeing for item in my_obj: understands iteration immediately. But that simple syntax depends on __iter__ working correctly. Without it? Error. With it? Magic. The beauty of this system is that every Python developer already knows the interface, they just might not know they're relying on dunders to deliver it.
How Python Uses Dunders
Understanding that dunders exist is one thing. Understanding how Python actually calls them, and in what order, is what separates developers who use them from developers who use them correctly. Python follows a precise lookup chain every time you use an operator or a built-in function.
When you write a + b, Python first tries a.__add__(b). If that method returns NotImplemented (not None, not False, specifically the singleton NotImplemented), Python flips the operands and tries b.__radd__(a). This reflected method lookup is what allows numeric types from different libraries to interoperate gracefully. NumPy scalars can add themselves to Python integers because both sides cooperate through this protocol.
Built-in functions follow a similar pattern. Calling len(obj) doesn't just look up obj.len(), it goes through Python's C-level machinery to call type(obj).__len__(obj), which means the method lookup happens on the type, not the instance. This distinction matters when you're doing metaclass work or trying to understand why you can't just assign obj.__len__ = lambda: 42 on an instance and expect len(obj) to use it. Python is strict about method dispatch for the performance-critical built-ins.
The order also matters for comparisons. Python's comparison protocol tries the left operand first, then the right operand's reflected method, and finally falls back to identity comparison for equality or raises TypeError for ordering. When you return NotImplemented, you're not raising an error, you're telling Python to keep looking. This cooperative fallback chain is what makes Python's operator system extensible without requiring every type to know about every other type. The protocol is the interface, and NotImplemented is how types signal they don't know how to handle a particular combination.
Object Lifecycle: Birth and Death
Every object in Python has a lifecycle. It gets created, used, and cleaned up. Three dunders control this entire process.
__new__ and __init__: The Creation Pipeline
Most of the time, you only write __init__. But Python actually calls __new__ first. The separation exists because allocating memory and configuring state are genuinely different concerns, and sometimes you need to intercept the allocation step directly.
class Person:
def __new__(cls, name):
print(f"1. __new__ called, creating instance")
instance = super().__new__(cls)
return instance
def __init__(self, name):
print(f"2. __init__ called, initializing instance")
self.name = name
p = Person("Alice")
# Output:
# 1. __new__ called, creating instance
# 2. __init__ called, initializing instanceWhat's happening: __new__ allocates memory and returns a new instance. __init__ receives that instance and configures it. The new → init sequence is immutable.
The hidden layer: __new__ is what you override when you need to control whether an instance gets created at all. Singleton pattern? Caching? Object pooling? That's __new__ territory. The fact that __new__ receives the class (cls) rather than an instance means you can return an instance of a completely different class, useful for factory patterns where the exact type isn't known until runtime.
Here's a practical example:
class Singleton:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
s1 = Singleton()
s2 = Singleton()
print(s1 is s2) # True - same object in memoryEvery call to Singleton() returns the exact same instance because __new__ checks the cache first. Notice that __init__ still runs each time even though __new__ returns the cached instance, if that matters for your use case, you'll need to guard against re-initialization inside __init__ as well.
__del__: Cleanup Time
When an object is about to be garbage collected, Python calls __del__. Use it for cleanup: closing files, releasing resources, logging. It sounds like the right tool for resource management, but as we'll see, there's a better way.
class FileManager:
def __init__(self, filename):
self.file = open(filename, 'r')
print(f"Opened {filename}")
def __del__(self):
self.file.close()
print(f"Closed file")
fm = FileManager("data.txt")
del fm # Triggers __del__
# Output:
# Opened data.txt
# Closed fileWarning: Don't rely on __del__ for critical cleanup. Use context managers (with statement) instead, that's what they're designed for. __del__ runs when garbage collection happens, which is unpredictable. In CPython, objects with no circular references are collected immediately when their reference count hits zero, so __del__ often runs quickly. But in PyPy, Jython, or any implementation using a tracing garbage collector, the timing is completely undefined. If you need guaranteed cleanup, use __enter__ and __exit__ instead.
String Representation: How Your Object Looks
When you print an object or inspect it in the REPL, you're calling one of two dunders. They do different jobs, and conflating them is one of the most common beginner mistakes in Python OOP.
__repr__ vs __str__
The rule of thumb is simple: __repr__ is for developers, __str__ is for end users. If you only implement one, implement __repr__, Python will fall back to it when __str__ is missing, but not the other way around.
class Book:
def __init__(self, title, author):
self.title = title
self.author = author
def __repr__(self):
return f"Book(title={self.title!r}, author={self.author!r})"
def __str__(self):
return f"{self.title} by {self.author}"
book = Book("1984", "George Orwell")
print(str(book)) # 1984 by George Orwell (human-friendly)
print(repr(book)) # Book(title='1984', author='George Orwell') (developer-friendly)The difference: __str__ is for end users (pretty, readable). __repr__ is for developers (complete, unambiguous, ideally copy-paste-able).
The hidden layer: The Pythonic ideal is that __repr__ returns a string that, when eval'd, recreates the object. Not always possible, objects with external state like database connections or file handles can't be recreated from a string, but it's worth aiming for in pure data classes. When you see a list printed in the REPL, that's __repr__ at work: [1, 2, 3] is both readable and evaluable, which is why Python lists feel so transparent.
__format__: Custom Formatting
The f-string {obj:format_spec} calls __format__. This is a less commonly known dunder, but it's incredibly useful when your objects have multiple meaningful representations depending on context.
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
def __format__(self, format_spec):
if format_spec == 'c':
return f"{self.celsius}°C"
elif format_spec == 'f':
return f"{self.celsius * 9/5 + 32}°F"
else:
return str(self.celsius)
temp = Temperature(20)
print(f"Temperature: {temp:c}") # Temperature: 20°C
print(f"Temperature: {temp:f}") # Temperature: 68.0°FWhy this matters: You can give your objects custom formatting rules that integrate with f-strings. No special methods needed, just the format spec. This is how Python's built-in datetime objects support format strings like {dt:%Y-%m-%d}: they implement __format__ and parse the spec themselves. Your custom types can do the same.
Comparison Operators: The Equality Problem
Python gives you six comparison operators. Defining them is where many developers slip up, particularly around the distinction between identity (is) and equality (==).
The Basic Six
The comparison dunders follow a clear naming convention, but the devil is in the details. Notice how we use tuple comparison to handle the multi-field case cleanly, this is a common Pythonic pattern that keeps comparison logic concise and correct.
class Version:
def __init__(self, major, minor, patch):
self.major = major
self.minor = minor
self.patch = 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 __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
def __ne__(self, other):
return not self == other
v1 = Version(1, 0, 0)
v2 = Version(1, 0, 1)
print(v1 < v2) # True
print(v1 == v2) # FalseThe catch: You'd need to write six methods. That's repetitive. That's where @total_ordering comes in. Also note the isinstance check: always validate the type of other before comparing. Returning NotImplemented (not raising TypeError) allows Python to try the reflected comparison on the other object, which is the cooperative approach the data model expects.
@total_ordering: Write Less, Get More
The functools module ships a decorator specifically designed to reduce the boilerplate of defining all six comparison methods. It's one of those standard library gems that every Python developer should know.
from functools import total_ordering
@total_ordering
class Version:
def __init__(self, major, minor, patch):
self.major = major
self.minor = minor
self.patch = 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 __lt__(self, other):
if not isinstance(other, Version):
return NotImplemented
return (self.major, self.minor, self.patch) < \
(other.major, other.minor, other.patch)
v1 = Version(1, 0, 0)
v2 = Version(1, 0, 1)
print(v1 < v2) # True
print(v1 <= v2) # True (generated)
print(v1 > v2) # False (generated)
print(v1 >= v2) # False (generated)
print(v1 != v2) # True (generated)The magic: You define __eq__ and one ordering method (__lt__), and @total_ordering generates the other four. Much cleaner.
The hidden layer: Always return NotImplemented if you can't compare with the other object's type. This lets Python try the reverse operation on the other object. There's a slight performance cost to @total_ordering because the generated methods add one extra function call compared to hand-written implementations, but in almost every real-world scenario that cost is negligible and the readability win is significant.
Operator Overloading Philosophy
Operator overloading is a feature that Python inherited from Simula and Smalltalk, refined over decades of real-world use. The core philosophy is deceptively simple: operators should do what users expect them to do for a given type. For numbers, + means addition. For strings, + means concatenation. For your custom Vector class, + should mean vector addition. The language doesn't dictate what + does, it just provides the hook, and you fill in the semantics.
This philosophy has an important corollary: don't overload operators in ways that violate expectations. If you define __add__ on a class that represents a user account and make it concatenate names, you're going to confuse every developer who reads that code. The expressiveness of operator overloading is directly proportional to how obvious the semantics are. When Money(10) + Money(20) gives you Money(30), everyone understands it immediately. When Request() + Response() gives you some merged object, you've created a puzzle.
The right question to ask before implementing any operator overload is: "Would a reasonable developer expect + (or *, or <, etc.) to mean this for objects of this type?" If the answer is yes, implement it. If the answer is maybe, document it heavily. If the answer is no, use a named method instead. Python gives you operator overloading as a tool to make code more expressive, it's not a mandate to use operators everywhere. A class that uses add() and subtract() methods instead of + and - is sometimes clearer, especially in domain areas where the mathematical metaphor doesn't fit naturally. Use this power deliberately.
Arithmetic Operators: Making Math Feel Native
When you write a + b, Python is really saying "try a.__add__(b), and if that returns NotImplemented, try b.__radd__(a)".
The Core Arithmetic Dunders
The reflected method pattern (__radd__, __rmul__, etc.) is what makes Python's numeric type system extensible. When you create a custom numeric type, you don't need to register it with the language, you just implement the right dunders and Python's dispatch mechanism handles the rest automatically.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
if isinstance(other, Vector):
return Vector(self.x + other.x, self.y + other.y)
return NotImplemented
def __sub__(self, other):
if isinstance(other, Vector):
return Vector(self.x - other.x, self.y - other.y)
return NotImplemented
def __mul__(self, scalar):
if isinstance(scalar, (int, float)):
return Vector(self.x * scalar, self.y * scalar)
return NotImplemented
def __rmul__(self, scalar):
# This allows 2 * vector in addition to vector * 2
return self.__mul__(scalar)
def __repr__(self):
return f"Vector({self.x}, {self.y})"
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2) # Vector(4, 6)
print(v1 - v2) # Vector(-2, -2)
print(v1 * 2) # Vector(2, 4)
print(2 * v1) # Vector(2, 4) (via __rmul__)Why __rmul__? The expression 2 * vector tries int.__mul__(2, vector) first. That fails. Python then tries vector.__rmul__(2). This symmetric design lets you support operations in both directions. Without __rmul__, your users would have to remember to always write vector * 2 instead of 2 * vector, which is an annoying and arbitrary restriction that breaks mathematical intuition.
In-Place Operators
In-place operators like += and *= have their own dunder methods, and the distinction from their non-in-place counterparts is critical for performance with large objects. For mutable types, in-place operations should modify the object directly rather than creating a new one.
class MutableVector:
def __init__(self, x, y):
self.x = x
self.y = y
def __iadd__(self, other):
if isinstance(other, MutableVector):
self.x += other.x
self.y += other.y
return self # Must return self
return NotImplemented
def __repr__(self):
return f"MutableVector({self.x}, {self.y})"
v = MutableVector(1, 2)
print(id(v)) # 140734...
v += MutableVector(3, 4)
print(v) # MutableVector(4, 6)
print(id(v)) # 140734... (same object)The key detail: __iadd__ modifies in place and returns self. Without it, Python falls back to __add__ and creates a new object. For small objects that difference doesn't matter, but for large data structures, think matrix operations or audio buffers, avoiding allocation on every operation can be the difference between acceptable and completely unacceptable performance.
Container Protocol: Acting Like a List
Make your objects feel like containers. Users expect len(), indexing, membership tests, and iteration. Implementing the container protocol fully means your objects work seamlessly with Python's built-in functions, list comprehensions, sorted(), min(), max(), and everything else that expects something iterable or sizeable.
__len__, __getitem__, __setitem__
These three dunders together give you the core of what Python considers a mutable sequence. Once you have __len__ and __getitem__, Python will even generate iteration for you automatically through the index-based fallback, though implementing __iter__ explicitly is still better practice for clarity and performance.
class Playlist:
def __init__(self):
self.songs = []
def __len__(self):
return len(self.songs)
def __getitem__(self, index):
return self.songs[index]
def __setitem__(self, index, song):
self.songs[index] = song
def add_song(self, song):
self.songs.append(song)
playlist = Playlist()
playlist.add_song("Song A")
playlist.add_song("Song B")
print(len(playlist)) # 2 (calls __len__)
print(playlist[0]) # Song A (calls __getitem__)
playlist[1] = "Song B+" # calls __setitem__What you get: Your object now works with len(), bracket notation, and slice notation automatically. If you pass a slice object to __getitem__, which Python does automatically when you write playlist[1:3], you get the slice object's start, stop, and step attributes to handle however makes sense for your container.
__contains__: The in Operator
The membership test operator is one of those dunders that's easy to overlook until you realize how frequently Python code uses in checks. Any time someone writes if item in collection:, they're relying on this method.
class Playlist:
def __init__(self):
self.songs = []
def __contains__(self, song):
return song in self.songs
def add_song(self, song):
self.songs.append(song)
playlist = Playlist()
playlist.add_song("Song A")
print("Song A" in playlist) # True (calls __contains__)
print("Song B" in playlist) # FalseWhy it matters: Without __contains__, Python falls back to iteration with __getitem__, which is slower. Define it explicitly for performance. More importantly, define it explicitly for correctness, the fallback iteration uses == comparison on each element, which might not be what you want for objects with custom equality semantics.
__iter__ and __next__: The Iteration Protocol
Here's where magic gets real. Defining __iter__ makes your object loop-able. The iteration protocol is one of the most used features in Python, underpinning not just for loops but also list(), tuple(), set(), dict(), unpacking assignments, and * argument expansion.
class CountUp:
def __init__(self, max):
self.max = max
self.current = 0
def __iter__(self):
return self
def __next__(self):
if self.current < self.max:
self.current += 1
return self.current
else:
raise StopIteration
for num in CountUp(3):
print(num)
# Output:
# 1
# 2
# 3The contract: __iter__ returns an iterator object (often self). __next__ returns the next value or raises StopIteration when done. The for loop handles all the error catching. One subtlety: if your object is both an iterable (has __iter__) and an iterator (has __next__), and __iter__ returns self, you can only iterate over it once, because the current position is stored on the object itself. If you need to support multiple independent iterations simultaneously, return a new iterator object from __iter__ instead of self.
Alternatively, make it simpler with a generator function:
class CountUp:
def __init__(self, max):
self.max = max
def __iter__(self):
for i in range(1, self.max + 1):
yield i
for num in CountUp(3):
print(num)
# Same outputThe hidden layer: Generators are syntactic sugar for the iterator protocol. Under the hood, yield creates a generator object with __next__ built in. Generators are easier to write; iterator classes are sometimes clearer conceptually. The generator approach also automatically solves the multiple-iteration problem: every call to __iter__ creates a fresh generator object, so you can iterate over the same CountUp instance multiple times and each loop starts from the beginning.
Context Manager Protocol
The with statement is one of Python's most elegant features, and the protocol behind it is straightforward once you understand it. The key insight is that Python guarantees __exit__ will run no matter how the block exits, whether it completes normally, raises an exception, hits a return, or gets interrupted by a break or continue. This guarantee is what makes context managers the right tool for any kind of resource management.
The with statement is syntactic sugar for resource management. It calls __enter__ when you enter the block and __exit__ when you leave (even on exception). Without context managers, reliable resource cleanup requires try/finally blocks everywhere, which adds visual noise and makes it easy to forget the cleanup in the rush of writing logic. Context managers encapsulate that pattern once and let you use it everywhere cleanly.
class DatabaseConnection:
def __init__(self, connection_string):
self.connection_string = connection_string
self.connection = None
def __enter__(self):
print("Connecting to database...")
self.connection = f"DB({self.connection_string})"
return self.connection
def __exit__(self, exc_type, exc_val, exc_tb):
print("Closing database...")
self.connection = None
# Return True to suppress exceptions, False/None to propagate
return False
with DatabaseConnection("localhost") as db:
print(f"Using {db}")
# Output:
# Connecting to database...
# Using DB(localhost)
# Closing database...The guarantee: __exit__ runs no matter what, exception, return, break. This is rock-solid cleanup. The value returned by __enter__ is what gets bound to the variable in with ... as variable:, so you have full control over what the user of your context manager works with inside the block.
The hidden layer: The three parameters to __exit__ give you exception information. You can catch specific exceptions and handle them silently:
class DatabaseConnection:
def __exit__(self, exc_type, exc_val, exc_tb):
print("Closing database...")
if exc_type is ValueError:
print("Caught ValueError, suppressing...")
return True # Suppress the exception
return False # Propagate other exceptionsThis selective exception suppression is powerful. If you return True from __exit__, the exception is consumed and execution continues normally after the with block. If you return False or None, the exception propagates as it normally would. Real-world context managers use this to implement things like automatic transaction rollback on database errors, temporary directory cleanup even after crashes, and lock release even when exceptions occur inside critical sections.
Callable Objects: Making Instances Act Like Functions
If your object defines __call__, you can invoke it with parentheses. This blurs the line between objects and functions in a way that's extremely useful for certain patterns, particularly anywhere you need something that behaves like a function but also needs to maintain state between calls.
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, x):
return x * self.factor
times_three = Multiplier(3)
print(times_three(5)) # 15 (calls __call__)
print(times_three(10)) # 30Why this matters: You can create objects that act like functions but maintain state. A closure-like behavior with full OOP support. This is more flexible than closures for complex cases because you can inspect and modify the object's state from outside, subclass it, and give it additional methods.
Here's a decorator-like example:
class RateLimiter:
def __init__(self, max_calls):
self.max_calls = max_calls
self.call_count = 0
def __call__(self, func):
def wrapper(*args, **kwargs):
if self.call_count >= self.max_calls:
raise Exception("Rate limit exceeded")
self.call_count += 1
return func(*args, **kwargs)
return wrapper
limiter = RateLimiter(2)
@limiter
def api_call():
print("API called")
api_call() # Works
api_call() # Works
api_call() # Raises ExceptionThis pattern of using callable objects as decorators lets you create decorators with configurable state that persists across decorated function calls. Unlike a function-based decorator, you can inspect limiter.call_count from outside, reset it, or modify limiter.max_calls at runtime, none of which are easy to do with a simple closure-based decorator.
Attribute Access: The __getattr__ Family
Python gives you three ways to intercept attribute access. Each serves a different purpose. Getting these wrong, especially confusing __getattr__ and __getattribute__, is a reliable source of subtle, hard-to-debug infinite recursion.
__getattr__: The Fallback
Called only if the attribute lookup fails normally. This means __getattr__ is your safety net: it only activates when Python has already searched the instance's __dict__, the class, and all parent classes without finding the attribute. It's non-intrusive and safe to implement.
class FallbackDict:
def __init__(self):
self.data = {}
def __getattr__(self, name):
if name in self.data:
return self.data[name]
raise AttributeError(f"No attribute {name}")
obj = FallbackDict()
obj.data['foo'] = 'bar'
print(obj.foo) # bar (found in data dict via __getattr__)Use case: Delegating attribute access to an internal object. Dynamic attribute lookup. __getattr__ is how many proxy and delegation patterns work in Python, the object passes attribute lookups through to a wrapped object, making it appear to have all the attributes of the thing it wraps.
__setattr__: Intercept All Attribute Sets
Called every time you assign to an attribute. Unlike __getattr__, this one fires for every assignment, not just missing ones. That makes it more powerful and more dangerous.
class Validated:
def __setattr__(self, name, value):
if name == 'age' and not isinstance(value, int):
raise TypeError("age must be int")
super().__setattr__(name, value)
obj = Validated()
obj.age = 25 # OK
obj.age = "twenty" # TypeErrorWarning: __setattr__ is called for every attribute assignment, including initialization. Use super().__setattr__() to avoid infinite recursion. If you try to set an attribute inside __setattr__ by writing self.name = value, you'll recurse infinitely. Always delegate to super().__setattr__() or use object.__setattr__(self, name, value) directly.
__getattribute__: Total Control
Called for every attribute access, even before checking if the attribute exists. This is the nuclear option of attribute interception, it gives you complete control but requires extreme care to avoid breaking the object's own internal workings.
class Traced:
def __init__(self):
object.__setattr__(self, 'accesses', [])
def __getattribute__(self, name):
if name != 'accesses':
accesses = object.__getattribute__(self, 'accesses')
accesses.append(name)
return object.__getattribute__(self, name)
obj = Traced()
_ = obj.__dict__ # Traced
_ = obj.accesses # [__dict__, accesses]Use case: Performance monitoring, lazy loading, complete attribute interception. Rarely needed.
The hidden layer: __getattribute__ is more powerful but also more dangerous. Most problems that seem to need it are better solved with __getattr__ or properties. The critical rule is that inside __getattribute__, you must use object.__getattribute__(self, name) to access any attribute on self, if you write self.something, you'll trigger __getattribute__ recursively and overflow the call stack.
Common Dunder Mistakes
Even experienced Python developers make predictable mistakes with magic methods. Knowing these patterns lets you avoid the debugging sessions that cost hours over something that should take minutes.
The most common mistake is forgetting to return NotImplemented from arithmetic and comparison methods when the types don't match. Returning False from __eq__ when the other type is unknown seems harmless, but it breaks the cooperative comparison protocol. If a == b calls a.__eq__(b) and gets False (not NotImplemented), Python stops there, it never tries b.__eq__(a). This matters when your custom type needs to compare against types you don't control.
The second common mistake is modifying state in __hash__ or making a class both mutable and hashable. Python's rule is clear: if you define __eq__, Python sets __hash__ to None, making your class unhashable (and preventing it from being used as a dict key or set member). If you need both equality comparison and hashability, you must explicitly define __hash__, and then you take on the responsibility of ensuring that objects that compare equal always have the same hash value. Violating this breaks sets and dicts in subtle ways that are extremely hard to debug.
The third mistake is implementing __del__ for critical cleanup instead of __exit__. As mentioned earlier, __del__ timing is undefined. The fourth is implementing __getattribute__ when __getattr__ would suffice, the former intercepts everything and requires careful delegation, while the latter is safely additive. And the fifth, arguably most impactful, is forgetting to handle slices in __getitem__. When someone writes my_obj[1:3], Python passes a slice object to __getitem__. If your implementation only handles integers, it will crash or behave incorrectly when someone tries to slice your container.
Putting It All Together: A Complete Example
Let's build a practical class that uses multiple magic methods. The goal is a Money class that feels completely native to Python, something you could hand to another developer without any documentation and have them use correctly through intuition alone.
from functools import total_ordering
@total_ordering
class Money:
def __init__(self, amount, currency='USD'):
self.amount = amount
self.currency = currency
# Representation
def __repr__(self):
return f"Money({self.amount}, {self.currency!r})"
def __str__(self):
return f"{self.currency} {self.amount:.2f}"
def __format__(self, spec):
if spec == 'full':
return f"{self.currency} {self.amount:.2f}"
return f"${self.amount:.2f}"
# Comparison
def __eq__(self, other):
if not isinstance(other, Money):
return NotImplemented
return self.amount == other.amount and self.currency == other.currency
def __lt__(self, other):
if not isinstance(other, Money):
return NotImplemented
if self.currency != other.currency:
raise ValueError("Cannot compare different currencies")
return self.amount < other.amount
# Arithmetic
def __add__(self, other):
if isinstance(other, Money):
if self.currency != other.currency:
raise ValueError("Cannot add different currencies")
return Money(self.amount + other.amount, self.currency)
elif isinstance(other, (int, float)):
return Money(self.amount + other, self.currency)
return NotImplemented
def __radd__(self, other):
return self.__add__(other)
def __mul__(self, scalar):
if isinstance(scalar, (int, float)):
return Money(self.amount * scalar, self.currency)
return NotImplemented
def __rmul__(self, scalar):
return self.__mul__(scalar)
# In action
m1 = Money(10, 'USD')
m2 = Money(20, 'USD')
print(m1) # USD 10.00
print(f"{m1:full}") # USD 10.00
print(m1 + m2) # USD 30.00
print(m1 < m2) # True
print(m1 * 3) # USD 30.00
print(2 * m1) # USD 20.00What you see: A financial class that works seamlessly with Python operators. Addition uses __add__, comparison uses __lt__, multiplication uses __mul__ and __rmul__. To a user, it just feels like a native type. Notice that we also raise ValueError for currency mismatches rather than returning NotImplemented, that's a deliberate choice because we want to fail loudly when someone tries to add dollars to euros, not silently fall back to some other comparison path.
Summary: The Hidden Architecture
Magic methods are Python's bridge between syntax and implementation. Every operator, every built-in function, every idiom you love relies on them. When you define __add__, you're not just adding a method, you're teaching Python a new language.
Here's the practical takeaway: You don't need to memorize all 100+ dunders. Focus on the ones that match your use case:
- Representation:
__repr__,__str__,__format__ - Comparison:
__eq__,__lt__(with@total_ordering) - Arithmetic:
__add__,__mul__,__radd__ - Container:
__len__,__getitem__,__iter__ - Lifecycle:
__init__,__new__,__del__ - Resource management:
__enter__,__exit__ - Attribute access:
__getattr__,__setattr__
The hidden layer beneath all of this: Python trusts you to follow the contract. Define __iter__ but don't raise StopIteration? Weird behavior. Define __len__ but return a negative number? Python doesn't check. With great power comes great responsibility.
And that's the real magic, not that Python has these methods, but that they're designed so intuitively that your custom classes integrate seamlessly into the language itself. When you look at a library like numpy and see that array + array, len(array), array[1:3], and with np.errstate() as e: all work naturally, you're seeing hundreds of carefully implemented dunder methods working in concert. Every well-designed Python library is essentially a thoughtful collection of dunder implementations, and now you have the knowledge to build yours the same way.
The next time you find yourself writing a wrapper function or a special-purpose conversion utility, stop and ask: is there a dunder for this? There usually is.