Back to Blog
Python

Python Dunder Methods: Practical Usage and Pitfalls

python dunder methods: Learn how Python dunder methods control object behavior, operator overloading, and context management with practical examples and common pitfalls.

dunder methodsoperator overloadingpython object modelpython classespython performance
Illustration of Python dunder methods showing operator overloading and object lifecycle hooks.

Python dunder methods, also known as special methods, define how objects behave in common language constructs. They are the reason you can write len(obj), str(obj), obj + other, or with obj as x. These methods are not syntactic sugar; they are the hooks Python uses to integrate your classes with the rest of the language. Understanding them lets you design objects that feel native to Python rather than bolted-on abstractions.

Object Lifecycle: new and init

Most developers use __init__ to set up an object after it is created. But __new__ is the actual constructor — it allocates the instance. __init__ receives that instance and initializes it. For most classes, you only need __init__. However, __new__ becomes essential when you subclass immutable types like tuple or int, or when you want to control instance creation, such as returning a cached instance.

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

Here, __new__ ensures only one instance exists. __init__ will still be called each time you call Singleton(), but the same object is returned. If you need to reset state on each call, you must handle that in __init__.

__del__ is the destructor, but it is not called immediately when an object goes out of scope. It runs when the garbage collector reclaims the object, which can be later or never in some interpreter implementations. Relying on __del__ for resource cleanup is fragile; prefer context managers or finally blocks.

String Representation: str and repr

__repr__ is meant for developers and debugging. It should return an unambiguous string that often looks like a valid Python expression that recreates the object. __str__ is for end users and is used by print() and str(). If __str__ is missing, Python falls back to __repr__.

class Point: def __init__(self, x, y): self.x = x self.y = y def __repr__(self): return f"Point({self.x!r}, {self.y!r})" def __str__(self): return f"({self.x}, {self.y})"

Now repr(Point(1, 2)) gives Point(1, 2) and str(Point(1, 2)) gives (1, 2). When you log objects or use them in f-strings, __repr__ is what you see in many debugging contexts. A good __repr__ should be unambiguous, while __str__ can be more human-friendly.

Operator Overloading with Dunder Methods

Operator overloading is one of the most visible uses of dunder methods. Methods like __add__, __eq__, __lt__, and __contains__ let your objects work with Python's operators and built-in functions.

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 __eq__(self, other): if not isinstance(other, Vector): return NotImplemented return (self.x, self.y) == (other.x, other.y) def __repr__(self): return f"Vector({self.x!r}, {self.y!r})"

Returning NotImplemented for unsupported types is critical. It tells Python to try the reflected operation on the other operand (e.g., __radd__), and if that also fails, raise TypeError. Without it, you might get incorrect behavior or obscure errors.

For comparison operators, you can implement __eq__ and __lt__ and use functools.total_ordering to fill in the rest, but that adds overhead and can hide performance issues. Explicitly implementing only the operators you need is often clearer.

Context Managers: enter and exit

Context managers are a clean way to manage resources. The with statement calls __enter__ when entering the block and __exit__ when leaving, even if an exception is raised.

class ManagedFile: def __init__(self, filename, mode): self.filename = filename self.mode = mode def __enter__(self): self.file = open(self.filename, self.mode) return self.file def __exit__(self, exc_type, exc_val, exc_tb): self.file.close() return False # propagate exception if any

The __exit__ method receives the exception type, value, and traceback if an exception occurred. Returning True suppresses the exception; returning False lets it propagate. In most cases, you want False unless you intentionally handle the exception inside the context manager.

For simple cases, the contextlib.contextmanager decorator can reduce boilerplate, but a class-based context manager is more explicit when you need to manage state across multiple with blocks or when the logic is complex.

Attribute Access: getattr, setattr, and getattribute

These methods control how attribute access works. __getattr__ is called only when normal attribute lookup fails. __getattribute__ is called for every attribute access, which makes it powerful but dangerous. __setattr__ intercepts all attribute assignments.

class LazyProxy: def __init__(self, obj): self._obj = obj def __getattr__(self, name): # Called only if name is not found normally return getattr(self._obj, name) def __setattr__(self, name, value): if name.startswith('_'): super().__setattr__(name, value) else: setattr(self._obj, name, value)

Using __getattr__ for delegation is common in proxy patterns. But be careful with __setattr__: any assignment inside the method triggers recursion unless you use object.__setattr__ or super().__setattr__. The same recursion risk applies to __getattribute__. In practice, you rarely need __getattribute__; __getattr__ covers most dynamic behavior.

Memory and Performance: slots

By default, Python instances use a dictionary to store attributes. That gives flexibility but costs memory. __slots__ replaces the per-instance dictionary with a fixed-size array, which reduces memory and can improve attribute access speed.

class PointWithSlots: __slots__ = ('x', 'y') def __init__(self, x, y): self.x = x self.y = y ```n Instances of this class cannot have attributes not listed in `__slots__`. That is a feature: it prevents typos from silently creating new attributes. However, it also breaks code that relies on dynamic attributes, and it complicates inheritance because subclasses must define their own `__slots__` or they will get a `__dict__` again. When to use `__slots__`? If you create millions of small objects, the memory savings are significant. For typical application code, the added rigidity may not be worth it. Profile first; do not add `__slots__` prematurely. ## Common Pitfalls and Maintainability Dunder methods are easy to misuse. A frequent mistake is implementing `__eq__` without `__hash__`. When you define `__eq__`, Python sets `__hash__` to `None` unless you explicitly define it. This makes your objects unhashable, which breaks use in sets and as dictionary keys. If your objects are mutable, that is actually the correct behavior; if they are immutable, implement `__hash__` consistently. Another pitfall is overloading operators for types that are not semantically numeric. For example, using `__add__` to concatenate custom objects can confuse readers. Prefer explicit method names like `merge()` or `concat()` when the operation is not obviously additive. When you implement `__getattr__`, be aware that it is called for any missing attribute, including internal methods like `__copy__` or `__deepcopy__`. This can lead to unexpected behavior if you are not careful. Always check the attribute name or delegate to a known set. Finally, dunder methods are part of the public contract of your class. Changing their behavior in a subclass can break code that relies on the parent's semantics. Document the intended behavior and write tests that exercise these methods directly, especially when they involve operator overloading or context management.
python dunder methods: Practical Usage and Code Examples | RYUSLOG DEV