Python contextlib — Deep Dive

The context manager protocol

Before diving into contextlib’s advanced features, it helps to understand the raw protocol. A context manager implements two methods:

class ManagedResource:
    def __enter__(self):
        # Setup: acquire resource
        # Return value becomes the "as" variable
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        # Cleanup: release resource
        # Return True to suppress the exception, False/None to propagate
        self.close()
        return False

The __exit__ signature receives exception info if one occurred inside the with block. This is the mechanism behind contextlib’s exception handling capabilities.

@contextmanager exception handling in depth

The @contextmanager decorator wraps your generator in a class that handles the protocol. When an exception occurs in the with block, it’s thrown into the generator at the yield point:

from contextlib import contextmanager

@contextmanager
def transaction(conn):
    conn.begin()
    try:
        yield conn
    except Exception:
        conn.rollback()
        raise           # re-raise to propagate
    else:
        conn.commit()   # only if no exception

If you catch the exception and don’t re-raise it, the exception is suppressed:

@contextmanager
def suppress_value_errors():
    try:
        yield
    except ValueError:
        pass  # exception suppressed — with block appears to succeed

with suppress_value_errors():
    int("not a number")  # ValueError suppressed
print("continues normally")

This is powerful but dangerous — suppressing exceptions silently can hide bugs. Be explicit about what you’re catching.

Async context managers

contextlib provides async equivalents for all major features:

@asynccontextmanager

from contextlib import asynccontextmanager

@asynccontextmanager
async def async_db_session(pool):
    conn = await pool.acquire()
    try:
        yield conn
    finally:
        await pool.release(conn)

async with async_db_session(pool) as conn:
    await conn.execute("SELECT 1")

AsyncExitStack

from contextlib import AsyncExitStack

async def process_connections(urls):
    async with AsyncExitStack() as stack:
        connections = []
        for url in urls:
            conn = await stack.enter_async_context(connect(url))
            connections.append(conn)
        # all connections managed, all cleaned up on exit

ExitStack advanced patterns

Transferring ownership

ExitStack.pop_all() transfers all registered cleanups to a new stack, letting you prevent cleanup when you want to return a resource to the caller:

from contextlib import ExitStack

def open_managed_files(paths):
    """Open files; caller takes ownership of cleanup."""
    with ExitStack() as stack:
        files = [stack.enter_context(open(p)) for p in paths]

        # Validate all files before returning
        for f in files:
            if f.read(1) == "":
                raise ValueError(f"Empty file: {f.name}")
            f.seek(0)

        # Transfer cleanup responsibility to caller
        return stack.pop_all(), files

# Caller receives cleanup_stack and must close it
cleanup, files = open_managed_files(["a.txt", "b.txt"])
try:
    process(files)
finally:
    cleanup.close()  # closes all files

Conditional context managers

from contextlib import ExitStack

def process_data(path, use_profiling=False, use_lock=False):
    with ExitStack() as stack:
        if use_profiling:
            stack.enter_context(profiler())
        if use_lock:
            stack.enter_context(file_lock(path))

        data = open(path).read()
        return transform(data)

Cleanup callbacks with arguments

from contextlib import ExitStack

with ExitStack() as stack:
    tmpdir = create_temp_dir()
    stack.callback(shutil.rmtree, tmpdir)

    # push with specific exception info
    stack.callback(print, "cleanup complete", flush=True)

Callbacks are called in LIFO order, same as atexit.

AbstractContextManager and AbstractAsyncContextManager

These ABCs (from contextlib) provide default implementations and can be used for type checking:

from contextlib import AbstractContextManager

class Timer(AbstractContextManager):
    def __enter__(self):
        self.start = time.perf_counter()
        return self

    def __exit__(self, *exc):
        self.elapsed = time.perf_counter() - self.start
        return False  # don't suppress exceptions

Using AbstractContextManager gives you a __enter__ that returns self by default, so you only need to implement __exit__.

chdir context manager (Python 3.11+)

Python 3.11 added contextlib.chdir():

from contextlib import chdir
import os

print(os.getcwd())  # /home/user

with chdir("/tmp"):
    print(os.getcwd())  # /tmp
    # do work in /tmp

print(os.getcwd())  # /home/user — restored automatically

Thread safety warning: os.chdir() affects the entire process. In multi-threaded applications, chdir() is not safe — use absolute paths instead.

Exception handling patterns

Selective exception suppression with logging

from contextlib import contextmanager
import logging

logger = logging.getLogger(__name__)

@contextmanager
def log_and_suppress(*exception_types):
    try:
        yield
    except exception_types as e:
        logger.warning("Suppressed exception: %s: %s", type(e).__name__, e)

Exception transformation

@contextmanager
def convert_exceptions(from_type, to_type):
    try:
        yield
    except from_type as e:
        raise to_type(str(e)) from e

with convert_exceptions(KeyError, ValueError):
    data = {"a": 1}
    _ = data["missing"]  # KeyError → ValueError

Retry context manager

@contextmanager
def retry_on_failure(max_retries=3, delay=1):
    """Not a true context manager pattern — shows yield-once limitation."""
    for attempt in range(max_retries):
        try:
            yield attempt
            return  # success
        except Exception as e:
            if attempt == max_retries - 1:
                raise
            time.sleep(delay)

Note: this doesn’t work as written because @contextmanager requires exactly one yield. For retry logic, use a regular function or loop — context managers aren’t the right abstraction.

Production patterns

Database transaction manager

from contextlib import contextmanager

@contextmanager
def transaction(engine, isolation_level="READ COMMITTED"):
    conn = engine.connect()
    conn.execute(f"SET TRANSACTION ISOLATION LEVEL {isolation_level}")
    conn.begin()
    try:
        yield conn
    except Exception:
        conn.rollback()
        raise
    else:
        conn.commit()
    finally:
        conn.close()

Temporary environment variables

from contextlib import contextmanager
import os

@contextmanager
def env_vars(**kwargs):
    old_values = {}
    for key, value in kwargs.items():
        old_values[key] = os.environ.get(key)
        if value is None:
            os.environ.pop(key, None)
        else:
            os.environ[key] = value
    try:
        yield
    finally:
        for key, old in old_values.items():
            if old is None:
                os.environ.pop(key, None)
            else:
                os.environ[key] = old

with env_vars(DATABASE_URL="sqlite:///test.db", DEBUG="1"):
    run_tests()
# Original env vars restored

Timing and profiling

from contextlib import contextmanager
import time

@contextmanager
def timed(label, threshold_ms=None):
    start = time.perf_counter()
    yield
    elapsed_ms = (time.perf_counter() - start) * 1000
    msg = f"{label}: {elapsed_ms:.1f}ms"
    if threshold_ms and elapsed_ms > threshold_ms:
        logger.warning(f"SLOW: {msg}")
    else:
        logger.debug(msg)

Performance notes

The @contextmanager decorator adds minimal overhead — about 2-3μs per with block entry/exit compared to a raw class-based context manager (~0.5μs). For code that enters context managers millions of times per second, the class-based approach is faster. For everything else, the readability of @contextmanager wins.

ExitStack adds more overhead (~5μs per entry) because it maintains a list of callbacks. This is negligible unless you’re managing hundreds of resources in a tight loop.

One thing to remember

contextlib’s power comes from composability — @contextmanager for simple setup/cleanup, ExitStack for dynamic resource counts, suppress for clean exception handling, and async variants for the async world. Master these four and you can express any resource management pattern cleanly.

pythonstandard-libraryresource-management

See Also

  • Python Atexit How Python's atexit module lets your program clean up after itself right before it shuts down.
  • Python Bisect Sorted Lists How Python's bisect module finds things in sorted lists the way you'd find a word in a dictionary — by jumping to the middle.
  • Python Copy Module Why copying data in Python isn't as simple as it sounds, and how the copy module prevents sneaky bugs.
  • Python Dataclass Field Metadata How Python dataclass fields can carry hidden notes — like sticky notes on a filing cabinet that tools read automatically.
  • Python Datetime Handling Why dealing with dates and times in Python is trickier than it sounds — and how the datetime module tames the chaos