Customizing Classes with Magic Methods0%

Customizing Classes with Magic Methods

Beginner12 min readUpdated: Jul 11, 2026
Study Materials

Customizing Classes with Magic Methods

Beyond basic arithmetic and string representations, Python's Data Model enables deep customization of user-defined classes. By implementing container protocols, callable behaviors, and attribute access hooks, you can create objects that behave like custom dictionaries, custom lists, dynamic proxies, or stateful function pipelines.


1. Emulating Containers: Sequence and Mapping Protocols

In Python, an object becomes a collection or container by implementing the Sequence Protocol or Mapping Protocol:

OperationDunder MethodExample Syntax
Read element__getitem__(self, key)val = container[key] or container[start:stop]
Assign element__setitem__(self, key, value)container[key] = value
Delete element__delitem__(self, key)del container[key]
Membership check__contains__(self, item)if item in container:
Length count__len__(self)total = len(container)
Output
Indexing Expression: container[key]
Does key represent a slice?
┌────────────────┴────────────────┐
YES NO
│ │
▼ ▼
key is slice(...) key is discrete
(e.g., [start:stop:step]) (e.g., int, str, tuple)
│ │
└────────────────┬────────────────┘
Invoke: container.__getitem__(key)

Implementing a Typed, Key-Value Memory Store

Python
from typing import Any, Iterator, Union
 
class CaseInsensitiveDictionary:
"""A dictionary-like container that normalizes string keys to lowercase."""
 
def __init__(self) -> None:
self._store: dict[str, Any] = {}
 
def _normalize_key(self, key: Any) -> str:
if not isinstance(key, str):
raise TypeError(f"Key must be a string, got {type(key).__name__}")
return key.lower()
 
def __setitem__(self, key: str, value: Any) -> None:
norm_key = self._normalize_key(key)
self._store[norm_key] = value
 
def __getitem__(self, key: str) -> Any:
norm_key = self._normalize_key(key)
return self._store[norm_key]
 
def __delitem__(self, key: str) -> None:
norm_key = self._normalize_key(key)
del self._store[norm_key]
 
def __contains__(self, key: object) -> bool:
if not isinstance(key, str):
return False
return key.lower() in self._store
 
def __len__(self) -> int:
return len(self._store)
 
def __repr__(self) -> str:
return f"CaseInsensitiveDictionary({self._store!r})"
 
# Testing the custom mapping
headers = CaseInsensitiveDictionary()
headers["Content-Type"] = "application/json"
headers["Authorization"] = "Bearer token_xyz"
 
# Access with varied casings
print(headers["content-type"]) # "application/json"
print(headers["CONTENT-TYPE"]) # "application/json"
print("authorization" in headers) # True
print(f"Total headers: {len(headers)}")

2. Handling Slices in __getitem__

When an indexing expression includes colons (e.g. obj[1:5:2]), Python constructs a built-in slice object and passes it to __getitem__.

Python
class RollingWindow:
"""Demonstrates handling discrete integer indices as well as slice objects."""
 
def __init__(self, data: list[int]) -> None:
self._data = list(data)
 
def __getitem__(self, index: Union[int, slice]):
if isinstance(index, slice):
# slice objects contain .start, .stop, and .step
print(f"Intercepted slice: start={index.start}, stop={index.stop}, step={index.step}")
return self._data[index]
elif isinstance(index, int):
return self._data[index]
raise TypeError("Indices must be integers or slices.")
 
window = RollingWindow([10, 20, 30, 40, 50, 60, 70])
print("Single item:", window[2]) # 30
print("Slice item: ", window[1:5:2]) # [20, 40]

3. Callable Instances: The __call__ Method

By implementing __call__(self, *args, **kwargs), an instance can be invoked directly with parentheses obj(), behaving like a function while maintaining persistent internal state.

Python
class ExponentialBackoff:
"""Stateful retry delay calculator implementing the __call__ protocol."""
 
def __init__(self, base_delay: float = 1.0, factor: float = 2.0, max_delay: float = 60.0) -> None:
self.base_delay = base_delay
self.factor = factor
self.max_delay = max_delay
self.attempts = 0
 
def __call__(self) -> float:
"""Computes next backoff duration and increments internal attempt counter."""
delay = min(self.base_delay * (self.factor ** self.attempts), self.max_delay)
self.attempts += 1
return delay
 
def reset(self) -> None:
self.attempts = 0
 
# Using instance as a callable function
backoff = ExponentialBackoff(base_delay=1.0, factor=2.0)
 
print(f"Is backoff callable? {callable(backoff)}") # True
print(f"Attempt 1 delay: {backoff():.1f}s") # 1.0s
print(f"Attempt 2 delay: {backoff():.1f}s") # 2.0s
print(f"Attempt 3 delay: {backoff():.1f}s") # 4.0s
print(f"Attempt 4 delay: {backoff():.1f}s") # 8.0s

4. Attribute Interception: __getattr__ vs __getattribute__

Python provides distinct hooks for intercepting attribute access:

Output
Attribute Access: obj.attr
Always calls __getattribute__
┌────────────────┴────────────────┐
▼ ▼
Attribute Found Attribute NOT Found
Returns attribute value (Raises AttributeError)
Calls __getattr__
(Fallback handler)
  • __getattribute__(self, name): Intercepts every single attribute access unconditionally. Modifying this requires extreme caution to avoid infinite recursion.
  • __getattr__(self, name): The graceful fallback. It is only called if the attribute was NOT found in normal dictionary lookup.
Python
class DynamicRecordProxy:
"""Proxies data access dynamically using __getattr__."""
 
def __init__(self, record_data: dict[str, Any]) -> None:
# Bypass custom attribute hooks during initialization
super().__setattr__("_data", record_data)
 
def __getattr__(self, name: str) -> Any:
"""Called only when the attribute does not exist on self."""
if name in self._data:
return self._data[name]
raise AttributeError(f"Record has no attribute '{name}'")
 
def __setattr__(self, name: str, value: Any) -> None:
"""Intercepts attribute assignments to update the inner dictionary."""
if name == "_data":
super().__setattr__(name, value)
else:
self._data[name] = value
 
record = DynamicRecordProxy({"user": "admin", "role": "root", "active": True})
 
# Dynamic property access
print("User via attribute:", record.user)
print("Role via attribute:", record.role)
 
# Setting new attribute dynamically
record.cluster = "us-east-1"
print("Newly assigned dynamic attribute:", record.cluster)

5. Architectural Comparison Summary

MethodTrigger SyntaxCritical Caveat
__getitem__obj[key]Must handle slice objects if sequence behavior is expected.
__setitem__obj[key] = valShould validate keys according to container domain rules.
__contains__item in objMust return a boolean. If omitted, Python falls back to iterating with __iter__.
__call__obj(*args)Makes callable(obj) == True. Ideal for stateful closures and middleware.
__getattr__obj.attrFallback only; runs only when normal lookup fails.
__getattribute__obj.attrAlways runs. Must use super().__getattribute__ to avoid infinite recursion.

Multiple Choice Questions

1.

Which dunder method is executed when an element is retrieved via square bracket notation value = obj["my_key"]? A. __get__ B. __getattr__ C. __getitem__ D. __access__

Answer: C
Explanation:The subscript indexing operation obj[key] is mapped directly to __getitem__(self, key).

2.

What object type does Python pass as the key argument to __getitem__ when an expression like obj[2:10:2] is executed? A. A tuple containing (2, 10, 2) B. A built-in slice object with start=2, stop=10, step=2 C. A range object range(2, 10, 2) D. A string "2:10:2"

Answer: B
Explanation:Python creates a slice(2, 10, 2) instance and passes it directly to __getitem__ when colon slicing syntax is used.

3.

What built-in function returns True when evaluated on an instance whose class defines the __call__ method? A. isfunction() B. callable() C. hasattr() D. isinstance(obj, FunctionType)

Answer: B
Explanation:The built-in callable(obj) returns True for any object whose class implements the __call__ dunder method.

4.

What is the key difference between __getattr__ and __getattribute__? A. __getattr__ is called on every attribute access, while __getattribute__ is only a fallback. B. __getattribute__ is called unconditionally on every attribute lookup, while __getattr__ is only called if standard attribute resolution fails. C. __getattr__ is private, whereas __getattribute__ is public. D. __getattribute__ is deprecated in Python 3.

Answer: B
Explanation:__getattribute__ intercepts every attribute access unconditionally. __getattr__ is only invoked as a fallback mechanism when the attribute is not found in the object's instance dictionary or class hierarchy.

5.

Inside __getattribute__, how should you safely access attributes on the instance without triggering infinite recursion? A. Directly access self.__dict__[name] B. Call self.name C. Delegate via super().__getattribute__(name) or object.__getattribute__(self, name) D. Call getattr(self, name)

Answer: C
Explanation:Accessing self.__dict__ or calling getattr(self, ...) inside __getattribute__ triggers __getattribute__ again, causing infinite recursion and a RecursionError. One must use super().__getattribute__(name).

Next Lesson

Project: Vector Class with Overloaded Operators

Continue learning with hands-on practice, examples, and exercises in the upcoming topic.

Practice Quiz

Test your understanding of this lesson with 5 questions. Each question has one correct answer.

PrevNext