Python’s dunder (double-underscore) methods — also called magic methods or special methods — let your custom classes integrate seamlessly with Python’s built-in operations. When Python evaluates len(my_object), it calls my_object.__len__(). When you write post1 == post2, Python calls post1.__eq__(post2). When you use an object in a with statement, Python calls its __enter__ and __exit__. Implementing these methods is how you make your classes feel like native Python types — and many FastAPI and SQLAlchemy operations rely on them behind the scenes.
__str__ and __repr__
class Post:
def __init__(self, id: int, title: str, published: bool = False):
self.id = id
self.title = title
self.published = published
def __repr__(self) -> str:
"""Machine-readable — for developers, logging, debuggers.
Convention: return a string that could recreate the object."""
return f"Post(id={self.id!r}, title={self.title!r}, published={self.published!r})"
def __str__(self) -> str:
"""Human-readable — for print() and str().
If __str__ is not defined, Python falls back to __repr__."""
status = "✓" if self.published else "draft"
return f"[{status}] {self.title} (id={self.id})"
post = Post(1, "Hello World", True)
print(post) # [✓] Hello World (id=1) — uses __str__
repr(post) # Post(id=1, title='Hello World', published=True) — uses __repr__
str(post) # [✓] Hello World (id=1)
f"Post: {post}" # Post: [✓] Hello World (id=1) — f-string uses __str__
# In a list — Python uses __repr__ for each item
posts = [Post(1, "A"), Post(2, "B")]
print(posts) # [Post(id=1, ...), Post(id=2, ...)] — __repr__ used
__repr__ on your classes. Without it, you get unhelpful output like <__main__.Post object at 0x7f8a1b2c3d40> which is useless for debugging. __repr__ is shown in the interactive shell, in logging output, and whenever you print a list containing your objects. __str__ is for user-facing output where readability matters more than technical detail — if only one is defined, __repr__ is used everywhere.!r format specifier in f-strings to call repr() on a value: f"{name!r}" wraps a string in quotes, which makes __repr__ output unambiguous. Compare: Post(title=Hello World) (ambiguous — is Hello World a string or variable?) vs Post(title='Hello World') (unambiguous — clearly a string). The !r specifier handles this automatically.__repr__ should not call methods that could fail (database queries, network calls, file reads). It is called during debugging when the object may be in a partially-initialised state. Keep it simple — just format the object’s core attributes. If your __repr__ raises an exception, Python shows the exception instead of the representation, which defeats its entire purpose.__eq__ and __hash__
class Post:
def __init__(self, id: int, title: str):
self.id = id
self.title = title
def __eq__(self, other) -> bool:
"""Two posts are equal if they have the same id."""
if not isinstance(other, Post):
return NotImplemented # let Python try the other way
return self.id == other.id
def __hash__(self) -> int:
"""Hash based on id — must be consistent with __eq__.
If a == b, then hash(a) must == hash(b)."""
return hash(self.id)
def __lt__(self, other) -> bool:
"""Less-than for sorting."""
if not isinstance(other, Post):
return NotImplemented
return self.id < other.id
p1 = Post(1, "Hello")
p2 = Post(1, "Hello") # same id — different object in memory
p3 = Post(2, "World")
print(p1 == p2) # True — same id (uses __eq__)
print(p1 == p3) # False — different id
print(p1 is p2) # False — different objects in memory
# __hash__ enables use in sets and as dict keys
post_set = {p1, p2, p3}
print(len(post_set)) # 2 — p1 and p2 are "equal" → deduplicated
post_dict = {p1: "first post"}
print(post_dict[p2]) # "first post" — p2 hashes/equals to p1
# __lt__ enables sorting
sorted([p3, p1, p2]) # [Post(1, ...), Post(1, ...), Post(2, ...)]
__len__, __getitem__, __contains__
class PostCollection:
def __init__(self):
self._posts = []
def add(self, post):
self._posts.append(post)
def __len__(self) -> int:
"""Called by len(collection)."""
return len(self._posts)
def __getitem__(self, index):
"""Called by collection[0] or collection[1:3]."""
return self._posts[index]
def __contains__(self, post) -> bool:
"""Called by 'post in collection'."""
return post in self._posts
def __iter__(self):
"""Called by 'for post in collection'."""
return iter(self._posts)
def __repr__(self) -> str:
return f"PostCollection({len(self._posts)} posts)"
col = PostCollection()
p1 = Post(1, "Hello")
p2 = Post(2, "World")
col.add(p1); col.add(p2)
print(len(col)) # 2 — __len__
print(col[0]) # Post(1, ...) — __getitem__
print(col[0:1]) # [Post(1, ...)] — __getitem__ with slice
print(p1 in col) # True — __contains__
for post in col: # __iter__
print(post)
__enter__ and __exit__ — Context Managers
class DatabaseTransaction:
"""Context manager for database transactions."""
def __init__(self, db_connection):
self.db = db_connection
def __enter__(self):
"""Called at the start of 'with' block — return value bound to 'as' target."""
print("BEGIN TRANSACTION")
self.db.begin()
return self # 'with DatabaseTransaction(db) as tx:' → tx = self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Called at end of 'with' block — even if an exception occurred.
exc_type: exception class or None
exc_val: exception instance or None
exc_tb: traceback or None
Return True to suppress the exception; False/None to propagate it.
"""
if exc_type is None:
print("COMMIT")
self.db.commit()
else:
print(f"ROLLBACK (due to {exc_type.__name__})")
self.db.rollback()
return False # do not suppress exceptions
# Usage — automatically commits or rolls back
with DatabaseTransaction(conn) as tx:
conn.execute("INSERT INTO posts ...")
conn.execute("UPDATE users ...")
# If any exception: rollback; otherwise: commit
# contextlib.contextmanager — function-based alternative
from contextlib import contextmanager
@contextmanager
def timer(label: str):
import time
start = time.time()
yield # code in the with block runs here
elapsed = time.time() - start
print(f"{label}: {elapsed:.3f}s")
with timer("database query"):
results = db.query(Post).all()
# database query: 0.012s
Common Mistakes
Mistake 1 — Defining __eq__ without __hash__
❌ Wrong — Python sets __hash__ to None when you define __eq__ without __hash__:
class Post:
def __eq__(self, other): return self.id == other.id
# __hash__ is now None!
post = Post()
{post} # TypeError: unhashable type: 'Post'
✅ Correct — always define both together:
def __hash__(self): return hash(self.id) # ✓
Mistake 2 — Returning a wrong type from __eq__
❌ Wrong — returning None instead of bool:
def __eq__(self, other):
if self.id == other.id:
return True # forgot the False case — returns None for inequality!
✅ Correct — always return a bool or NotImplemented:
def __eq__(self, other):
if not isinstance(other, Post): return NotImplemented
return self.id == other.id # always returns True or False ✓
Mistake 3 — Raising in __exit__ accidentally suppresses the original exception
❌ Wrong — raising a new exception in __exit__ replaces the original:
def __exit__(self, exc_type, exc_val, exc_tb):
self.db.close()
if something_wrong:
raise RuntimeError("cleanup failed") # original exception lost!
✅ Correct — log cleanup errors but let original propagate:
def __exit__(self, exc_type, exc_val, exc_tb):
try:
self.db.close()
except Exception as cleanup_err:
print(f"Cleanup error: {cleanup_err}") # log, don't raise
return False # propagate original exception ✓
Quick Reference — Common Dunder Methods
| Method | Called By | Returns |
|---|---|---|
__init__(self, ...) |
MyClass(...) |
None |
__repr__(self) |
repr(obj), debugger, list display |
str |
__str__(self) |
print(obj), str(obj), f-string |
str |
__eq__(self, other) |
obj == other |
bool or NotImplemented |
__hash__(self) |
hash(obj), set/dict membership |
int |
__lt__(self, other) |
obj < other, sorted() |
bool or NotImplemented |
__len__(self) |
len(obj) |
int ≥ 0 |
__getitem__(self, key) |
obj[key] |
value |
__contains__(self, item) |
item in obj |
bool |
__iter__(self) |
for x in obj |
iterator |
__enter__(self) |
with obj as x: |
resource |
__exit__(self, ...) |
end of with block |
bool (suppress?) |