Dunder Methods — Making Classes Pythonic

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
Note: Always implement at least __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.
Tip: Use the !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.
Warning: __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?)

🧠 Test Yourself

You define __eq__ on your Post class but not __hash__. Later you try to add a Post to a set. What happens and why?