Why context managers matter

Every Python developer has felt the pain of a file left open, a database connection that never returns to the pool, or a timer that keeps running after the block finishes. The with statement solves exactly this by guaranteeing that cleanup code runs, even when an exception bubbles up. I reach for a custom context manager whenever I see a setup‑/teardown pair repeated across the codebase — it turns a fragile pattern into a single, reusable abstraction.

Building a reusable timer

Profiling a hot path is a classic use case. Instead of sprinkling time.perf_counter() calls everywhere, wrap the region in a timer that logs the elapsed milliseconds automatically.

import time
import logging
from contextlib import contextmanager

@contextmanager
def timed_block(name: str):
    """Log how long the wrapped block takes."""
    start = time.perf_counter()
    try:
        yield
    finally:
        elapsed = (time.perf_counter() - start) * 1000
        logging.info(f'{name} took {elapsed:.2f} ms')

# Usage
with timed_block('payload‑serialization'):
    data = serialize(payload)
    send(data)

The finally clause guarantees the log line appears even if serialize raises. Because the helper is a generator decorated with @contextmanager, the boilerplate of a full class disappears while the semantics stay identical.

Real‑world: database transaction wrapper

In a service that talks to PostgreSQL, every write operation should either commit cleanly or roll back on error. A context manager makes the contract explicit and prevents the dreaded "forgot to commit" bug.

from contextlib import contextmanager
import psycopg2

@contextmanager
def transaction(conn):
    """Yield a cursor that commits on success, rolls back on failure."""
    cur = conn.cursor()
    try:
        yield cur
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        cur.close()

# In a request handler
with transaction(db_conn) as cur:
    cur.execute('INSERT INTO events (type, payload) VALUES (%s, %s)',
                (event_type, json.dumps(payload)))

Notice the raise after rollback — it preserves the original traceback for upstream error handling. The caller never sees a raw cursor; they only interact with the logical unit of work.

When to prefer a class‑based manager

Generator‑based managers are concise, but a class gives you explicit __enter__ and __exit__ methods, which is handy when you need to store state across the block or expose a richer API.

class TempDirectory:
    def __init__(self, prefix='tmp_'):
        self.prefix = prefix
        self.path = None

    def __enter__(self):
        import tempfile, os
        self.path = tempfile.mkdtemp(prefix=self.prefix)
        return self.path

    def __exit__(self, exc_type, exc, tb):
        import shutil
        if self.path and os.path.exists(self.path):
            shutil.rmtree(self.path, ignore_errors=True)
        # Return False to propagate any exception
        return False

# Usage
with TempDirectory('build_') as workdir:
    compile_sources(workdir)
    run_tests(workdir)

The class cleanly isolates the temporary directory lifecycle and can be extended — for example, adding a list_files() helper — without cluttering the calling code.

Common pitfalls and how to avoid them

  • Swallowing exceptions: Returning True from __exit__ or omitting raise in a generator silences errors. Always let the exception propagate unless you deliberately handle it.
  • Resource leakage in __enter__: If __enter__ raises, __exit__ is never called. Perform only cheap, non‑resource‑acquiring work there; acquire heavy resources inside the try block of a generator or after the yield.
  • Re‑entrancy: A manager instance shouldn't be reused across nested with statements unless you explicitly design it for that. Create a fresh instance each time.

Putting it all together

Context managers are the Pythonic way to express "do this, then guarantee that". Whether you need a quick timer, a transaction guard, or a temporary workspace, the pattern reduces boilerplate, centralizes cleanup, and makes the intent obvious to the next reader — often yourself six months later. Start by identifying every try/finally pair in your codebase; each is a candidate for a tiny, well‑named manager. The investment pays off in fewer bugs, clearer logs, and a codebase that feels more maintainable.