Senior 9 min · March 05, 2026

Python Magic Methods — Set Corruption When __hash__ Missing

Orders missing from set when __eq__ defined without __hash__ triggers TypeError and silent corruption.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Magic methods are hooks Python calls automatically on operator, attribute, or built-in usage — they are looked up on the class type, not the instance, so monkey-patching a dunder on a single object has no effect
  • __init__ initializes instances; __new__ controls creation itself — override __new__ only for singletons or immutable subclasses
  • __repr__ must be unambiguous for debugging; __str__ is user-friendly — if you only implement one, implement __repr__
  • __eq__ and __hash__ must agree; break this rule and sets and dicts silently corrupt — implementing __eq__ without __hash__ makes the object unhashable with TypeError
  • __slots__ saves 40–60% memory per instance but requires every class in the hierarchy to define its own slots, and @dataclass(slots=True) is the safer way to get the same benefit in Python 3.10+
  • __getattr__ is a fallback for missing attributes only; __getattribute__ intercepts every access — confusing the two is a common source of silent bugs
  • __call__ makes any instance callable; __enter__ and __exit__ power the with statement — both are more production-common than most tutorials acknowledge
Plain-English First

Imagine you buy a fancy coffee machine. Out of the box it already knows how to turn on, make a sound when it is done, and show its status on a little screen — you did not program any of that, it just came built-in. Python magic methods are the same idea: they are pre-agreed slots that Python calls automatically when certain things happen to your object, like printing it, adding two of them together, or checking if they are equal. You fill in the slot, Python does the calling. The important detail is that Python looks for that slot on the class, not on the individual object. You cannot give one specific coffee machine a custom startup sound by sticking a label on it — you have to update the model's factory spec.

Every time Python evaluates len(my_list), compares two objects with ==, or prints something with print(), it is secretly delegating that work to a special method buried inside the object's class. This is not magic in the stage-trick sense — it is a precisely defined protocol that makes Python's data model tick. Understanding it is the difference between writing classes that play nicely with the rest of Python's ecosystem and writing classes that feel bolted-on and awkward.

Before magic methods existed as a concept, languages forced you to register callbacks or inherit from a god-class just to make your objects behave like built-in types. Python solved this with a clean contract: implement a double-underscore method with a specific name (hence 'dunder'), and the interpreter will call it at the right moment. The result is that your custom Vector class can support +, len(), slicing, context managers, and even pickling — all without inheriting from anything.

By the end of this article you will understand not just the syntax but the CPython internals that make these calls happen, the subtle ordering rules Python follows when resolving them, the performance traps hiding in __getattr__ and __slots__, how __call__ and __enter__/__exit__ work in production, why __del__ is almost always the wrong answer to resource cleanup, and the patterns senior engineers use in production libraries. You will also have concrete answers for the interview questions that trip up even experienced Python developers.

What Are Magic Methods? The CPython Dispatch Contract

Magic methods are special method names with double underscores on both ends that Python's interpreter calls implicitly when you use certain syntax or built-in operations. The 'magic' is not runtime wizardry — it is a well-defined protocol baked into CPython's bytecode execution and the C-level type structure.

When you write len(obj), CPython calls type(obj).__len__(obj), not obj.__len__() directly. Python looks up the method on the class, not the instance. This distinction is critical: if you try to monkey-patch a dunder onto a single object — obj.__len__ = lambda: 42 — it will have no effect when you call len(obj). The built-in function goes through the type, always.

The same dispatch applies to obj + other: Python looks up __add__ on type(obj), then __radd__ on type(other), then raises TypeError if both return NotImplemented. This two-step lookup is why int + float works — int.__add__ returns NotImplemented for floats, so Python falls back to float.__radd__. The protocol enables cooperation between types that neither owns the other.

A practical guide for choosing when to implement a dunder versus a regular method: - Use a dunder when you want Python syntax (+, len(), str(), ==) to work naturally on your object. - Use a regular named method when you need a descriptive API — add_item() is clearer than __add__ for domain-specific logic. - Use __lt__ and related comparison dunders when you want your objects to work with sorted(), min(), and max(). - Use __call__ when you want an instance to behave like a function — this is more common in production than tutorials suggest.

The key mental model: each dunder is a slot in CPython's type structure (PyTypeObject). If the class defines the dunder, Python fills the slot. If not, the slot is NULL and the built-in raises TypeError. This is also why __getattr__ cannot intercept len() — __getattr__ operates at the Python level, but len() goes through a C-level slot that bypasses Python attribute lookup entirely.

custom_vector.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

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

    def __add__(self, other):
        if not isinstance(other, Vector):
            return NotImplemented  # signals: I cannot handle this type
        return Vector(self.x + other.x, self.y + other.y)

    def __radd__(self, other):
        # Called when other.__add__(self) returned NotImplemented
        return self.__add__(other)

    def __len__(self):
        # Euclidean magnitude, truncated to int (len() requires an int)
        return int((self.x**2 + self.y**2)**0.5)


v1 = Vector(3, 4)
v2 = Vector(1, 2)
print(v1 + v2)   # calls Vector.__add__ -> Vector(4, 6)
print(len(v1))   # calls Vector.__len__ -> 5

# Attempting to monkey-patch __len__ on the instance has no effect
v1.__len__ = lambda: 999
print(len(v1))   # still 5 — len() goes through type(v1).__len__, not the instance
Output
Vector(4, 6)
5
5
The Slot Dispatch Model — Why Instance Monkey-Patching Fails
Python's built-in functions (len, str, +) look up dunder methods on the class type, not the instance. This is a C-level optimisation baked into CPython's type structure. If you assign obj.__len__ = lambda: 42 directly on an instance, len(obj) ignores it completely. The same applies to all dunders. To override behaviour, you must define the method on the class — either by subclassing or by modifying the class itself. This is also why __getattr__ cannot intercept len() calls: __getattr__ operates at the Python attribute level, while len() goes through a C slot that bypasses Python attribute lookup entirely.
Production Insight
If you override __getattribute__ but not the dunder slots, built-in functions like len() still use the C-level slot and ignore your override. This means __getattr__ cannot intercept len() calls — surprising if you relied on it for logging. To intercept len(), override __len__ directly. To intercept all attribute access including dunders, you need a proxy class with explicitly defined dunders, not just __getattribute__.
Key Takeaway
Magic methods are type-level slots, not instance methods. Python's built-in functions go through the C-level type structure, not __getattr__. You cannot dynamically add a dunder to a single instance and expect len() or + to use it — the lookup always goes through the class.

__init__ vs __new__: Object Creation Under the Hood

Most developers know __init__ as the constructor, but technically __new__ is the true constructor — it allocates the object. __init__ only initializes an already-created instance. This distinction matters when you need immutable objects or when you subclass immutable types like tuple or str.

__new__ receives the class as its first argument and must return an instance, usually by calling super().__new__(cls). If __new__ returns an instance of a different class, __init__ is NOT called. That is not a bug — it is a deliberate rule: __init__ only runs if __new__ returned an instance of the class being constructed.

In production, you rarely override __new__ unless you need a singleton, a flyweight, or to subclass an immutable type. For 95% of Python classes, __init__ is all you need.

When should you actually reach for __new__
  • Subclassing immutable types (tuple, str, int, frozenset) — __init__ cannot modify an already-created immutable, so modification must happen in __new__.
  • Singleton or flyweight patterns — __new__ can return an existing cached instance.
  • Factory patterns where the returned type depends on the arguments — __new__ can return a subclass instance.

One rule worth memorising: if __new__ returns an instance of a completely different class, __init__ is skipped. This is how some serialisation libraries work — they call __new__ to allocate the shell of an object, then populate attributes directly without going through __init__.

custom_tuple.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class ImmutablePoint(tuple):
    """A point that IS a tuple — immutable by nature.

    We must use __new__ because tuple is immutable.
    By the time __init__ would run, the tuple's content is already fixed.
    Trying to set values in __init__ would raise an error.
    """

    def __new__(cls, x, y):
        # super().__new__ allocates the tuple with the given content
        instance = super().__new__(cls, (x, y))
        return instance

    # No __init__ needed — content is fixed at allocation time

    def __repr__(self):
        return f"ImmutablePoint(x={self[0]}, y={self[1]})"


pt = ImmutablePoint(3, 4)
print(pt)                          # ImmutablePoint(x=3, y=4)
print(pt[0], pt[1])                # 3 4
print(isinstance(pt, tuple))       # True

# Demonstrates singleton pattern via __new__
class Singleton:
    _instance = None

    def __new__(cls, *args, **kwargs):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance  # always returns the same object

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

s1 = Singleton(10)
s2 = Singleton(20)
print(s1 is s2)    # True — same object
print(s1.value)    # 20 — __init__ ran twice on the same instance
Output
ImmutablePoint(x=3, y=4)
3 4
True
True
20
Common Trap: __new__ Returning the Wrong Type Skips __init__
If __new__ returns an instance of a class different from the one being created, Python does NOT call __init__. This can leave your object uninitialized and cause AttributeErrors later in ways that are extremely hard to trace. Always call super().__new__(cls) and return an instance of cls unless you have a very deliberate reason to return something else.
Production Insight
Overriding __new__ in a class that uses __slots__ requires extra care. __new__ must allocate space for the slots — Python's default __new__ handles this automatically if you call super().__new__(cls). Forgetting to call super().__new__ results in a TypeError at instantiation time. In the Singleton pattern, note that __init__ still runs on every call even though __new__ returns the same object — so initialisation logic in __init__ will overwrite state on every construction, as shown in the example above.
Key Takeaway
__new__ allocates the object; __init__ initializes it. If __new__ returns an instance of a different class, __init__ is skipped. Override __new__ only for singletons, immutable type subclasses, or factory patterns. For everything else, __init__ is the right place.

__str__ vs __repr__: Which to Use for Debugging and Logging

Both __repr__ and __str__ return string representations, but the contract is different. __repr__ should be unambiguous — ideally a string you could pass to eval() to recreate the object. __str__ should be readable to an end user. Python uses __str__ for print() and str(), and __repr__ for the interactive interpreter and the f-string debug format (f"{obj!r}").

If you define only one, define __repr__. When __str__ is missing, Python falls back to __repr__. The reverse is not true: if __str__ is defined but __repr__ is not, the interactive interpreter and logging frameworks that call repr() still show the default <__main__.MyClass object at 0x...> — completely useless in a production log.

A practical rule from years of on-call experience: if you ship a class to production without __repr__, you will eventually spend time staring at a log file trying to figure out which object was which. It takes about five minutes to implement and saves hours over the lifetime of the service.

For sensitive data — passwords, API keys, personal information — mask the value in __repr__ rather than omitting it entirely. Showing User(id=42, email='a***@example.com') is far more useful for debugging than User(id=42) and still protects the data.

user_class.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class User:
    def __init__(self, user_id, name, api_key):
        self.user_id = user_id
        self.name = name
        self._api_key = api_key  # sensitive — must not appear in logs

    def __repr__(self):
        # Unambiguous, shows class name and constructor arguments
        # Masks the sensitive api_key field — shows existence but not value
        return (
            f"User(user_id={self.user_id!r}, name={self.name!r}, "
            f"api_key='***')"
        )

    def __str__(self):
        # Human-readable for display — no internal details
        return f"{self.name} (ID: {self.user_id})"


u = User(42, 'Alice', 'secret-key-abc123')
print(repr(u))    # unambiguous, safe to log
print(str(u))     # user-friendly display
print(f"{u!r}")  # same as repr(u)
print(f"{u}")    # same as str(u)

# Fallback behaviour: if __str__ is missing, Python uses __repr__
class MinimalClass:
    def __repr__(self):
        return "MinimalClass()"

obj = MinimalClass()
print(str(obj))   # MinimalClass() — falls back to __repr__
Output
User(user_id=42, name='Alice', api_key='***')
Alice (ID: 42)
User(user_id=42, name='Alice', api_key='***')
Alice (ID: 42)
MinimalClass()
Senior Engineer Practice: Always Implement __repr__, Mask Sensitive Fields
Always include the class name and the key constructor arguments in __repr__. This makes debugging logs self-documenting — you can see exactly what object you are dealing with and reconstruct the call that created it. For sensitive data, show that the field exists but mask its value. Showing api_key='***' is far more useful than omitting the field, because you can confirm the key was set without exposing it.
Production Insight
If you only implement __str__ and not __repr__, logging frameworks that call repr() will show a useless memory address. This makes it impossible to distinguish objects in logs without extra context. Every class that gets passed to a logger, stored in an exception, or printed in a traceback deserves a __repr__. It is the highest-ROI dunder you can implement.
Key Takeaway
__repr__ is for developers and debugging; __str__ is for end users. Python falls back from __str__ to __repr__ if __str__ is missing, but not the reverse. Implement __repr__ in all your production classes — mask sensitive fields rather than omitting them.

__eq__ and __hash__: The Contract That Keeps Dicts and Sets Consistent

Python's dict and set rely on hash tables. When you look up a key, Python computes its hash (via __hash__) to find the bucket, then checks equality (via __eq__) to confirm the match. The contract is simple: if two objects are equal (__eq__ returns True), their hashes MUST be equal. Break this contract and you corrupt the data structure — objects disappear, duplicates appear, and lookups return wrong results.

Failure mode one — __eq__ without __hash__: Python implicitly sets __hash__ to None, making the object unhashable. Any attempt to add it to a set or use it as a dict key raises TypeError: unhashable type immediately. This is the loud, obvious failure.

Failure mode two — mutable hash: You implement both __eq__ and __hash__, but __hash__ is based on a mutable field. The object is inserted into a set. The field changes. Now the hash is different, the object is in the wrong bucket, and it can never be found or removed. The set appears to contain an object it cannot retrieve. This is the silent, dangerous failure — no exception, just corrupted state.

The fix for both is the same: base __hash__ only on immutable fields, and match those fields to what __eq__ uses.

For mutable objects where value-based equality is needed, the right answer is usually: do not implement __hash__ at all (leave it None explicitly), and use a list or a different data structure that does not require hashing. If you need to deduplicate mutable objects, extract an immutable key and deduplicate on that.

order_equality.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class Order:
    def __init__(self, order_id, symbol, quantity):
        self.order_id = order_id
        self.symbol = symbol
        self.quantity = quantity

    def __eq__(self, other):
        if not isinstance(other, Order):
            return NotImplemented
        return self.order_id == other.order_id

    def __hash__(self):
        # Hash only the immutable order_id — matches what __eq__ compares
        # Never hash mutable fields like quantity
        return hash(self.order_id)

    def __repr__(self):
        return f"Order(order_id={self.order_id!r}, qty={self.quantity})"


o1 = Order(1, 'AAPL', 100)
o2 = Order(1, 'AAPL', 200)  # same order_id, different quantity

order_set = {o1, o2}
print(order_set)         # one element — __eq__ and __hash__ agree
print(len(order_set))    # 1

# Demonstrating the mutable-hash silent corruption failure
class BrokenOrder:
    """Do NOT do this — __hash__ based on a mutable field."""
    def __init__(self, order_id):
        self.order_id = order_id

    def __eq__(self, other):
        return self.order_id == other.order_id

    def __hash__(self):
        return hash(self.order_id)  # dangerous if order_id can change

bad = BrokenOrder(1)
bad_set = {bad}
print(bad in bad_set)    # True before mutation

bad.order_id = 99        # mutate the field used in __hash__
print(bad in bad_set)    # False — object is lost in the wrong bucket
print(len(bad_set))      # 1 — the set still 'has' it but cannot find it
Output
{Order(order_id=1, qty=100)}
1
True
False
1
Two Failure Modes, One Root Cause
The loud failure: implement __eq__ without __hash__ and you get TypeError: unhashable type immediately on set insertion. The silent failure: implement __hash__ based on a mutable field and the object becomes unreachable in the set after mutation — no exception, just wrong behaviour. Both are prevented by the same rule: base __hash__ only on immutable fields, and match those fields exactly to what __eq__ compares.
Production Insight
The production incident in this article was caused by the loud failure mode first (TypeError on set insertion), which was incorrectly worked around by switching to lists. The list-based deduplication introduced the silent failure mode — mutable objects that changed after being added to the list could no longer be found or removed reliably. The real fix was five lines: implement __hash__ on the immutable order_id and write a test that inserts two equal objects into a set and asserts the set has exactly one element.
Key Takeaway
Equal objects must have equal hashes. Mutable fields in __hash__ cause silent data corruption when the field changes after insertion. Use @dataclass(frozen=True) to get both __eq__ and __hash__ for free while enforcing immutability.

__getattr__, __setattr__, and __delattr__: Attribute Access Control and Pitfalls

Python gives you fine-grained control over attribute access via three hooks: __getattr__ (fallback for failed lookups), __setattr__ (every attribute assignment), and __delattr__ (attribute deletion). __getattr__ is called only when normal attribute lookup fails — it is NOT the same as __getattribute__, which intercepts every access.

The difference matters enormously in practice. If you access an attribute that exists, __getattr__ is never called — __getattribute__ handles it. __getattr__ is specifically the fallback of last resort.

The underscore guard in the proxy example below deserves an explicit explanation because engineers routinely remove it thinking it is defensive noise. Without it, accessing self._config inside __getattr__ would trigger __getattr__ again (because _config might not exist yet during the early stages of __init__ before the line self._config = config executes). The guard raises AttributeError immediately for underscore-prefixed names, which tells Python to abort the lookup rather than recurse.

Inside __setattr__, never write self.x = value — that calls __setattr__ again and creates infinite recursion. Always delegate through super().__setattr__(name, value) or object.__setattr__(self, name, value) for attributes that need to bypass the custom logic.

proxy_object.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class ConfigProxy:
    """A proxy that delegates attribute reads to a backing dict.

    The underscore guard in __getattr__ is NOT optional.
    During __init__, before self._config is set, any attribute access
    that fails normal lookup will trigger __getattr__. Without the guard,
    accessing self._config inside __getattr__ would recurse indefinitely.
    The guard raises AttributeError immediately for private/internal names,
    which tells Python to abort the lookup cleanly.
    """

    def __init__(self, config: dict):
        # Routes through __setattr__ below, which uses super() for _config
        self._config = config

    def __getattr__(self, name):
        # This is only called when normal lookup FAILS (attribute not found)
        # The underscore guard prevents infinite recursion during __init__
        # and for any internal attribute that may not exist yet
        if name.startswith('_'):
            raise AttributeError(name)
        try:
            return self._config[name]
        except KeyError:
            raise AttributeError(f"Config key '{name}' not found")

    def __setattr__(self, name, value):
        if name == '_config':
            # Use super() for internal attributes to avoid infinite recursion
            # Writing self._config = value here would call __setattr__ again
            super().__setattr__(name, value)
        else:
            self._config[name] = value


cfg = ConfigProxy({'host': 'localhost', 'port': 5432})
print(cfg.host)          # 'localhost' — found in _config via __getattr__
print(cfg.port)          # 5432 — same path
cfg.timeout = 30         # routes through __setattr__ -> stored in _config
print(cfg.timeout)       # 30

try:
    print(cfg.missing_key)
except AttributeError as error:
    print(f"Correctly raised: {error}")
Output
localhost
5432
30
Correctly raised: Config key 'missing_key' not found
Infinite Recursion in __setattr__ — The Most Common Dunder Bug
Never use self.x = value inside __setattr__ — it calls __setattr__ again, causing infinite recursion and a RecursionError. The same applies to __getattr__: never access an attribute inside __getattr__ that might itself trigger __getattr__. Always route internal attribute assignments through super().__setattr__(name, value) or object.__setattr__(self, name, value). The underscore guard (raise AttributeError for names starting with underscore) is your safety net for early-construction scenarios.
Production Insight
A production outage once traced back to __getattr__ silently returning None for a misspelled configuration key. The application used a default that masked the error and caused incorrect behaviour for two weeks before anyone noticed. The fix was simple: raise AttributeError for missing keys, never return a default. Fail loudly, fix early. If you need a default value, provide a get() method with an explicit default argument — do not hide missing configuration inside attribute lookup.
Key Takeaway
__getattr__ is fallback only — it fires when normal lookup fails. __getattribute__ fires on every access. Inside __setattr__, always delegate through super() to avoid infinite recursion. The underscore guard in __getattr__ prevents recursion during early object construction — do not remove it.

__call__, __enter__, __exit__, and __del__ — The Production Dunders Nobody Teaches

Most tutorials cover __init__, __repr__, and __eq__ and stop there. But three other dunders appear constantly in production Python and deserve explicit coverage.

__call__ makes any instance behave like a function. Any class with __call__ defined can be invoked with parentheses — instance(args). This is how decorators implemented as classes work, how stateful callables maintain configuration, and how middleware layers stay composable without inheritance. In 2026, __call__ is especially common in ML inference pipelines where a model object is callable, and in dependency injection containers that need to produce objects on demand.

__enter__ and __exit__ power the with statement. __enter__ is called at the start of a with block and its return value is bound to the as variable. __exit__ is called at the end, whether the block exits normally or through an exception. __exit__ receives the exception type, value, and traceback — if it returns True, the exception is suppressed; if it returns False or None, the exception propagates. This is the correct pattern for resource management in Python, replacing try/finally in almost every case.

__del__ is the finalizer — it is called when an object's reference count drops to zero. It looks like the right place for cleanup, but it is unreliable in practice: it may not be called if there are reference cycles (CPython's cycle garbage collector handles those separately), it is never guaranteed to run in PyPy, and it is never guaranteed to run at all during interpreter shutdown. The rule is simple: if a resource must be released, use a context manager (__enter__/__exit__) or an explicit close() method. Reserve __del__ for logging or debugging only, never for resource release that must happen.

callable_and_context.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import time

# --- __call__: making an instance callable ---
class RateLimiter:
    """A callable that enforces a minimum interval between calls.
    __call__ makes the instance usable as a function or decorator.
    """

    def __init__(self, min_interval_seconds: float):
        self._min_interval = min_interval_seconds
        self._last_called = 0.0

    def __call__(self, func):
        """Wraps a function with rate limiting."""
        def wrapper(*args, **kwargs):
            elapsed = time.monotonic() - self._last_called
            if elapsed < self._min_interval:
                time.sleep(self._min_interval - elapsed)
            self._last_called = time.monotonic()
            return func(*args, **kwargs)
        return wrapper

    def __repr__(self):
        return f"RateLimiter(min_interval={self._min_interval}s)"


# --- __enter__ and __exit__: context manager protocol ---
class DatabaseConnection:
    """Manages a database connection lifecycle.
    __enter__ opens the connection; __exit__ closes it regardless of exceptions.
    This is the correct pattern for resource management — not __del__.
    """

    def __init__(self, connection_string: str):
        self._connection_string = connection_string
        self._connection = None

    def __enter__(self):
        print(f"Opening connection to {self._connection_string}")
        self._connection = f"<Connection:{self._connection_string}>"  # simulated
        return self  # bound to the 'as' variable in the with block

    def __exit__(self, exc_type, exc_val, exc_tb):
        print(f"Closing connection (exc_type={exc_type})")
        self._connection = None
        # Return False (or None) to let exceptions propagate
        # Return True only if you intentionally want to suppress them
        return False

    def query(self, sql: str) -> str:
        if self._connection is None:
            raise RuntimeError("Not connected. Use as a context manager.")
        return f"Result of [{sql}] on {self._connection}"

    def __repr__(self):
        return f"DatabaseConnection({self._connection_string!r})"


# __call__ in action
limiter = RateLimiter(0.0)  # zero interval for demo
print(f"RateLimiter is callable: {callable(limiter)}")

@limiter
def fetch_data():
    return "data"

result = fetch_data()
print(f"fetch_data result: {result}")

# __enter__ and __exit__ in action
with DatabaseConnection("postgres://localhost/mydb") as db:
    print(db.query("SELECT 1"))

# __del__ warning — never rely on it for resource cleanup
class LeakyResource:
    def __del__(self):
        # This may never be called in PyPy, during interpreter shutdown,
        # or if there are reference cycles. Use a context manager instead.
        print("__del__ called — do not rely on this for real cleanup")
Output
RateLimiter is callable: True
fetch_data result: data
Opening connection to postgres://localhost/mydb
Result of [SELECT 1] on <Connection:postgres://localhost/mydb>
Closing connection (exc_type=None)
__del__ Is Not a Destructor You Can Rely On
__del__ is called when an object's reference count reaches zero, but it is never guaranteed to run. Reference cycles prevent it (CPython's cycle GC handles those separately but __del__ complicates collection). PyPy may never call it. Interpreter shutdown may skip it. If a resource must be released — file handles, network sockets, database connections, GPU memory — use a context manager (__enter__/__exit__) or an explicit close() method. __del__ is for optional cleanup at best, and for logging at worst.
Production Insight
__call__ is one of the most underappreciated dunders in production code. Any time you have a class that wraps a function or acts as a configurable pipeline stage, __call__ makes it composable without inheritance. In ML serving, model objects are callable by convention — implementing __call__ on your model wrapper means it plugs directly into frameworks that expect a callable without any adapter layer. Context managers (__enter__/__exit__) are the correct resource management pattern in every Python codebase. The with statement is not just syntactic sugar — it guarantees __exit__ runs even if the block raises an exception.
Key Takeaway
__call__ makes any instance callable — essential for decorators, stateful pipelines, and ML model wrappers. __enter__ and __exit__ implement the context manager protocol and guarantee cleanup even on exception. __del__ is unreliable and should never be used for resource cleanup — use context managers instead.

__slots__: Memory Efficiency, Inheritance Rules, and @dataclass(slots=True)

__slots__ is a declarative way to prevent the creation of a per-instance __dict__, saving memory and speeding up attribute access. Each slot reserves space for an attribute as a direct pointer in a fixed-size array, bypassing the dict lookup entirely.

The memory saving is concrete. A regular class instance in CPython 3.12 carries a __dict__ that takes roughly 200–400 bytes even when empty, plus the standard instance overhead. A slotted instance replaces that with a fixed array of pointers — typically 40–50% smaller per instance. For a service that creates millions of geolocation points or financial tick records, that difference matters.

The complexity cost is real too. Every class in the inheritance hierarchy must define its own __slots__. A subclass that does not define __slots__ will have a __dict__ anyway, defeating the memory saving. Multiple inheritance with __slots__ requires careful coordination — if two parent classes both define non-empty __slots__, the subclass must be designed to avoid layout conflicts.

For private attributes with name mangling, the slot must use the mangled name. A class Foo with self.__price needs the slot named '_Foo__price', not '__price'. This is a common mistake that produces an AttributeError that looks nothing like a slots issue.

In Python 3.10+, @dataclass(slots=True) generates both the class and its __slots__ automatically, handles the mangled names correctly, and avoids most of the manual slot management pitfalls. This is the recommended way to use slots for most teams in 2026.

slots_example.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import sys
from dataclasses import dataclass

# --- Manual __slots__ ---
class Point2D:
    __slots__ = ('x', 'y')

    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

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


# --- @dataclass(slots=True) — Python 3.10+: the modern way ---
@dataclass(slots=True)
class Point2DDataclass:
    x: float
    y: float


# --- Comparison ---
regular_point = Point2D(3.0, 4.0)
slot_point = Point2DDataclass(3.0, 4.0)

print(f"Manual slots instance: {regular_point}")
print(f"Dataclass slots instance: {slot_point}")

# __slots__ prevents arbitrary attribute creation — this is a safety feature
try:
    regular_point.z = 5.0
except AttributeError as error:
    print(f"Slot safety caught it: {error}")

# Without __slots__, this would silently create a new attribute
class RegularPoint:
    def __init__(self, x, y):
        self.x = x
        self.y = y

rp = RegularPoint(3.0, 4.0)
rp.z = 5.0  # silently creates a new attribute — no error
print(f"Regular class allows accidental attr: {rp.z}")

# Private attribute with mangled name in slots
class PricedItem:
    __slots__ = ('name', '_PricedItem__price')  # mangled name required

    def __init__(self, name: str, price: float):
        self.name = name
        self.__price = price  # stored in '_PricedItem__price' slot

    @property
    def price(self):
        return self.__price


item = PricedItem("Widget", 9.99)
print(f"Price via property: {item.price}")
Output
Manual slots instance: Point2D(x=3.0, y=4.0)
Dataclass slots instance: Point2DDataclass(x=3.0, y=4.0)
Slot safety caught it: 'Point2D' object has no attribute 'z'
Regular class allows accidental attr: 5.0
Price via property: 9.99
Use @dataclass(slots=True) in Python 3.10+ — It Handles the Edge Cases for You
Manual __slots__ declarations require you to correctly mangle private attribute names, ensure every class in the inheritance hierarchy declares its own slots, and coordinate carefully under multiple inheritance. @dataclass(slots=True) handles the slot generation automatically, works correctly with dataclass inheritance, and is the recommended approach for new code in 2026. Reserve manual __slots__ for cases where you cannot use dataclasses — library code with complex inheritance, or classes that predate Python 3.10.
Production Insight
At scale, the memory difference between __dict__ and __slots__ compounds quickly. Ten million geolocation point instances at 200 bytes of __dict__ overhead each is two gigabytes of pure bookkeeping. The switch to slots dropped one production service from 2 GB to 800 MB with a one-line change. The follow-up lesson was harder: we later needed a per-instance debug flag for profiling. Because __slots__ was in place, we could not add it dynamically. We had to choose between adding 'debug' to __slots__ (requiring a redeploy) or maintaining a separate WeakValueDictionary keyed by object id. Plan ahead. If there is any chance you will need dynamic attributes later, either include '__dict__' in __slots__ explicitly or do not use slots.
Key Takeaway
__slots__ saves 40–60% memory per instance by replacing __dict__ with a fixed slot array. For private attributes, the slot must use the mangled name. @dataclass(slots=True) in Python 3.10+ is the safer, recommended approach — it handles naming and inheritance correctly. If you might need dynamic attributes later, include '__dict__' in __slots__ or skip slots entirely.
● Production incidentPOST-MORTEMseverity: high

The Silent Set Corruption: When __eq__ Without __hash__ Breaks Production

Symptom
Orders were sporadically missing from the order book. A workaround using lists instead of sets was applied under pressure, but that introduced duplicate orders that should have been filtered. Manual inspection showed the deduplication logic was silently broken.
Assumption
The team assumed that implementing __eq__ was sufficient for set membership because that is what equality means. When the first TypeError surfaced, an engineer removed the set entirely rather than diagnosing the root cause.
Root cause
Python requires that if __eq__ is defined, __hash__ must also be defined for the object to be usable in a set or as a dict key. When __eq__ is defined without __hash__, Python implicitly sets __hash__ to None, making any set insertion raise TypeError: unhashable type. The team replaced sets with lists to stop the TypeError, but the list-based deduplication check used == without any hash, so a second bug appeared: mutable Order objects whose attributes changed after insertion could no longer be found or removed reliably.
Fix
Added a __hash__ method hashing only the immutable order_id field, matching what __eq__ used. Enabled @dataclass(frozen=True) for new value objects going forward, which auto-generates both correctly and prevents mutation after construction. Added a unit test that verifies Order objects are correctly deduplicated in a set and that mutating a field after insertion raises FrozenInstanceError.
Key lesson
  • Never implement __eq__ without __hash__ unless you explicitly want unhashable instances — and if you want unhashable, set __hash__ = None explicitly so the intent is clear.
  • Use @dataclass(frozen=True) for value objects to avoid manual boilerplate and to enforce immutability.
  • Add a unit test that inserts two equal objects into a set and checks the set has exactly one element. That test would have caught this on day one.
  • When a TypeError surfaces in production and you apply a workaround under pressure, the root cause investigation still matters. The workaround may introduce a second bug that is harder to find.
Production debug guideSymptom to action guide for the most common dunder-related issues5 entries
Symptom · 01
len(my_obj) raises TypeError but the class appears to have __len__
Fix
Check if __len__ is defined on the class, not on an instance. Use type(obj).__len__ to verify. If __len__ is defined on the instance rather than the class, len() will not find it because Python looks up dunders on the type, not the object. Also confirm the method returns an integer — returning a float raises TypeError.
Symptom · 02
TypeError: unhashable type when inserting an object into a set or using it as a dict key
Fix
Check whether __eq__ is defined without __hash__. Print Order.__hash__ — if it is None, that is the problem. Implement __hash__ hashing the same fields used in __eq__, or use @dataclass(frozen=True) to generate both correctly.
Symptom · 03
Objects in a set appear multiple times or deduplication fails silently
Fix
This is the mutable-hash failure mode. Check whether any field used in __hash__ is mutable and is being changed after insertion. If the hash changes after insertion, the object is effectively lost in the bucket structure. Base __hash__ only on immutable fields, or make the object frozen.
Symptom · 04
AttributeError when accessing a defined attribute inside __getattr__
Fix
__getattr__ is only called for failed normal lookups. If your __getattr__ implementation accesses self.some_attr that also does not exist, it triggers __getattr__ again and you get recursion. Ensure the underscore guard (raise AttributeError for names starting with underscore) is in place and that __getattr__ never accesses attributes that might not exist yet.
Symptom · 05
AttributeError on an attribute you know exists, in a class using __slots__
Fix
Check for typos in the __slots__ tuple. Also check that the mangled name for private attributes is included — a class Foo with self.__price needs '_Foo__price' in __slots__, not '__price'. Use MyClass.__slots__ to inspect the declared names.
★ Quick Debug Cheat Sheet for Magic MethodsCommands to diagnose dunder-related problems fast
len() not working on custom container
Immediate action
Confirm __len__ is defined on the class, not the instance, and that it returns an int
Commands
python -c "from mymodule import MyClass; print(hasattr(MyClass, '__len__'))"
python -c "from mymodule import MyClass; obj = MyClass(); print(type(obj).__len__(obj))"
Fix now
Define def __len__(self): return ... returning an integer. If it returns a float or None, len() raises TypeError.
TypeError: unhashable type on set insertion+
Immediate action
Check whether __eq__ is defined without __hash__
Commands
python -c "from mymodule import Order; print(Order.__hash__)"
python -c "from mymodule import Order; o = Order(1); print(hash(o))"
Fix now
Define __hash__ hashing the same fields as __eq__. If the object must be unhashable, set __hash__ = None explicitly so the intent is documented.
Objects in set not deduplicated despite equal __eq__+
Immediate action
Check whether a mutable field used in __hash__ was changed after insertion
Commands
python -c "from mymodule import Order; o = Order(1); print(hash(o)); o.order_id = 2; print(hash(o))"
python -c "from mymodule import Order; s = {Order(1)}; o = list(s)[0]; o.order_id = 99; print(o in s)"
Fix now
Base __hash__ only on immutable fields. Use @dataclass(frozen=True) to prevent mutation after construction.
AttributeError on an existing attribute in a __slots__ class+
Immediate action
Inspect the slots declaration and check for mangled name issues
Commands
python -c "from mymodule import MyClass; print(MyClass.__slots__)"
python -c "from mymodule import MyClass; print([a for a in dir(MyClass) if not a.startswith('__')])"
Fix now
Check for typos in __slots__. For private attributes self.__price in class Foo, the slot must be named '_Foo__price'. Use @dataclass(slots=True) in Python 3.10+ to avoid manual slot naming entirely.
Magic Method Categories
CategoryMagic MethodsCommon Use Case
Object Creation__new__, __init__, __del__Custom initialization, singletons, immutable subclasses. Never use __del__ for resource cleanup — it is unreliable.
String Representation__repr__, __str__, __format__Debugging, logging, and display. __repr__ is mandatory; __str__ is optional. Always implement __repr__.
Comparison and Hashing__eq__, __ne__, __lt__, __le__, __gt__, __ge__, __hash__Custom equality, sorting, and use in sets and dicts. __eq__ and __hash__ must agree — break this and data structures corrupt silently.
Numeric Operators__add__, __sub__, __mul__, __truediv__, __radd__, __iadd__Custom arithmetic. Always return NotImplemented for unsupported types, not raise TypeError.
Attribute Access__getattr__, __getattribute__, __setattr__, __delattr__Lazy loading, proxies, access control. __getattr__ is fallback; __getattribute__ is all-access — know the difference.
Container Emulation__len__, __getitem__, __setitem__, __delitem__, __contains__Custom collections, sequences, and mappings.
Callable__call__Makes instances callable like functions. Used in decorators, stateful pipelines, ML model wrappers, and middleware.
Context Manager__enter__, __exit__Resource management with the with statement. The correct alternative to __del__ for guaranteed cleanup.
Iteration__iter__, __next__Custom iterators and generators. __iter__ returns self (or a separate iterator); __next__ returns the next value or raises StopIteration.

Key takeaways

1
Magic methods are type-level slots
Python's built-in functions (len, +, str) look them up on the class type, never the instance. Monkey-patching a dunder on an instance has no effect on built-in dispatch.
2
Always implement __hash__ when you implement __eq__, hashing only immutable fields. Implement __eq__ without __hash__ and you get unhashable objects. Hash a mutable field and objects silently disappear from sets after mutation.
3
__repr__ is for developers and debugging. __str__ is for end users. Python falls back to __repr__ if __str__ is missing
not the reverse. Implement __repr__ in every production class and mask sensitive fields.
4
__new__ allocates the object; __init__ initializes it. If __new__ returns a different class, __init__ is skipped. Override __new__ only for singletons, immutable type subclasses, or factory patterns.
5
__call__ makes instances callable
use it for decorators, stateful pipelines, and ML model wrappers. __enter__ and __exit__ implement the context manager protocol and guarantee cleanup even on exception. __del__ is unreliable — never use it for resource cleanup.
6
__slots__ saves 40–60% memory per instance. @dataclass(slots=True) in Python 3.10+ is the recommended way
it handles slot naming, mangling, and inheritance correctly. __getattr__ is fallback only; __getattribute__ intercepts everything.

Common mistakes to avoid

7 patterns
×

Defining __eq__ without __hash__

Symptom
Objects cannot be added to a set or used as dict keys — TypeError: unhashable type. Or, if __hash__ is based on a mutable field, objects silently become unreachable in sets after mutation.
Fix
Always define __hash__ when defining __eq__, hashing only immutable fields that match what __eq__ compares. Use @dataclass(frozen=True) to generate both correctly and enforce immutability.
×

Using self.attr = value inside __setattr__ without delegating to super()

Symptom
Infinite recursion and RecursionError on any attribute assignment.
Fix
Use object.__setattr__(self, name, value) or super().__setattr__(name, value) for internal assignments inside __setattr__.
×

Assuming __getattr__ intercepts all attribute access

Symptom
Existing attributes bypass __getattr__ entirely, so logging or validation inside __getattr__ misses them. Misspelled attribute names return defaults instead of raising AttributeError, masking bugs.
Fix
Use __getattr__ for fallback behaviour only. Use __getattribute__ for full interception. If you need both, call super().__getattribute__(name) inside __getattribute__ and handle AttributeError to delegate to __getattr__.
×

Using __slots__ without accounting for mangled private attribute names

Symptom
AttributeError on a private attribute even though __slots__ appears to include it. The slot is named '__price' but the mangled name is '_ClassName__price'.
Fix
Include the mangled name in __slots__. For class Foo with self.__price, the slot must be '_Foo__price'. Use @dataclass(slots=True) to avoid this entirely.
×

Using __del__ for resource cleanup

Symptom
File handles, sockets, or database connections are not released reliably. Memory leaks in long-running services. Behaviour differs between CPython and PyPy.
Fix
Implement __enter__ and __exit__ for context manager support. Use with statements at call sites. If an explicit close() method is needed, document it clearly and never rely on __del__ to call it.
×

Not implementing __repr__ in production classes

Symptom
Log output shows <__main__.MyClass object at 0x...>. On-call engineers cannot distinguish objects without attaching a debugger.
Fix
Always implement __repr__ returning a string that includes the class name and key constructor arguments. Mask sensitive fields rather than omitting them.
×

Monkey-patching a dunder on an instance expecting built-in functions to use it

Symptom
obj.__len__ = lambda: 42 is set but len(obj) still returns the original value or raises TypeError. The monkey-patch has no effect.
Fix
Dunder methods must be defined on the class, not the instance. Python's built-in functions look them up on the type. To change behaviour for one object, subclass and override the dunder on the subclass.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between __new__ and __init__? When would you over...
Q02SENIOR
Why is it important to implement __hash__ when you implement __eq__? Wha...
Q03SENIOR
Explain how __slots__ works under the hood. What are the performance ben...
Q04SENIOR
What is the difference between __getattr__ and __getattribute__? When wo...
Q05SENIOR
How does Python decide which __add__ or __radd__ to call when evaluating...
Q06SENIOR
What is __call__ and give a production use case where implementing it is...
Q01 of 06JUNIOR

What is the difference between __new__ and __init__? When would you override __new__?

ANSWER
__new__ is the actual constructor that allocates memory and returns an instance. __init__ only initializes the already-created instance — it does not allocate memory. You override __new__ when you need to control object creation, such as in the Singleton pattern (return an existing cached instance), when subclassing immutable types like tuple or str (because __init__ cannot modify an already-created immutable), or when implementing a factory that returns a subclass based on arguments. If __new__ returns an instance of a different class from the one being constructed, __init__ is not called at all.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
What is the most important magic method to implement in a custom class?
02
Can I add a magic method to an existing built-in type like list?
03
What happens if I implement __eq__ but not __hash__?
04
Should I use @dataclass or manually implement magic methods?
05
Why does my __getattr__ method cause infinite recursion?
06
When should I use __del__ for cleanup?
🔥

That's OOP in Python. Mark it forged?

9 min read · try the examples if you haven't

Previous
Encapsulation in Python
5 / 9 · OOP in Python
Next
Abstract Classes in Python