Python Context Managers: with Statement and contextlib

Context managers are Python's mechanism for deterministic resource cleanup. The with statement guarantees that __exit__ runs whether the block completes normally or raises an exception — eliminating the class of bugs caused by forgetting to close files, release locks, or roll back transactions. This guide covers the protocol, class-based and generator-based context managers, contextlib utilities, and async context managers.

How the with Statement Works

The with statement calls __enter__ on entry and __exit__ on exit — even if an exception is raised inside the block. The value returned by __enter__ is bound to the as target. __exit__ receives the exception type, value, and traceback if an exception occurred, or None, None, None on success. Returning a truthy value from __exit__ suppresses the exception.

# What Python does under the hood for:
#   with open("file.txt") as f:
#       data = f.read()

mgr = open("file.txt")
f = mgr.__enter__()
try:
    data = f.read()
except Exception as exc:
    if not mgr.__exit__(type(exc), exc, exc.__traceback__):
        raise
else:
    mgr.__exit__(None, None, None)

# Multiple context managers on one line (Python 3.10+: parenthesized)
with (
    open("input.txt") as fin,
    open("output.txt", "w") as fout,
):
    fout.write(fin.read().upper())

Class-Based Context Managers

Implement __enter__ and __exit__ to make any class usable as a context manager. This approach is best when the setup and teardown logic is complex or when you need to store state across the block.

import time
import logging

class Timer:
    """Context manager that measures elapsed time."""
    def __init__(self, name=""):
        self.name = name

    def __enter__(self):
        self.start = time.perf_counter()
        return self  # available as the 'as' target

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.elapsed = time.perf_counter() - self.start
        label = f"[{self.name}] " if self.name else ""
        print(f"{label}Elapsed: {self.elapsed:.4f}s")
        return False  # do not suppress exceptions

with Timer("data processing") as t:
    total = sum(range(10_000_000))
print(f"Got {total}, took {t.elapsed:.4f}s")

class DatabaseTransaction:
    """Wraps a DB connection in a transaction, rolling back on error."""
    def __init__(self, connection):
        self.conn = connection

    def __enter__(self):
        self.conn.execute("BEGIN")
        return self.conn

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is None:
            self.conn.execute("COMMIT")
        else:
            self.conn.execute("ROLLBACK")
            logging.error("Transaction rolled back: %s", exc_val)
        return False  # re-raise any exception

@contextmanager Decorator

The contextlib.contextmanager decorator lets you write a context manager as a generator function. Code before yield is the setup (__enter__), the yielded value becomes the as target, and code after yield in a finally block is the teardown (__exit__). This is often more readable than a full class.

from contextlib import contextmanager
import tempfile
import os

@contextmanager
def temp_directory():
    """Create a temporary directory, clean it up on exit."""
    import shutil
    tmpdir = tempfile.mkdtemp()
    try:
        yield tmpdir
    finally:
        shutil.rmtree(tmpdir, ignore_errors=True)

with temp_directory() as d:
    path = os.path.join(d, "work.txt")
    with open(path, "w") as f:
        f.write("temporary work")
    # d is automatically deleted here

@contextmanager
def change_directory(path):
    """Temporarily change the working directory."""
    old = os.getcwd()
    os.chdir(path)
    try:
        yield
    finally:
        os.chdir(old)

@contextmanager
def suppress_errors(*exceptions):
    """Context manager that silences specified exceptions."""
    try:
        yield
    except exceptions:
        pass

with suppress_errors(FileNotFoundError, PermissionError):
    os.remove("/nonexistent/file.txt")  # silently ignored
contextlib.suppress: The standard library already ships contextlib.suppress(*exceptions), which does the same as the custom suppress_errors above. Use it directly in production code.

contextlib Utilities

The contextlib module provides several ready-made context managers and helpers that solve common patterns without writing any boilerplate.

from contextlib import (
    suppress, redirect_stdout, redirect_stderr,
    ExitStack, nullcontext, contextmanager
)
import io

# suppress — ignore specific exceptions
with suppress(FileNotFoundError):
    os.unlink("maybe_exists.tmp")

# redirect_stdout — capture print() output
buffer = io.StringIO()
with redirect_stdout(buffer):
    print("captured output")
    print("more output")
text = buffer.getvalue()  # "captured output\nmore output\n"

# nullcontext — a no-op context manager, useful in conditional code
def process(data, lock=None):
    ctx = lock if lock is not None else nullcontext()
    with ctx:
        return [x * 2 for x in data]

# ExitStack — manage a dynamic number of context managers
files_to_process = ["a.txt", "b.txt", "c.txt"]
with ExitStack() as stack:
    handles = [stack.enter_context(open(f)) for f in files_to_process]
    for fh in handles:
        print(fh.read())
# all files closed on exit regardless of errors

# ExitStack as a reusable cleanup manager
class Application:
    def __init__(self):
        self._stack = ExitStack()

    def startup(self):
        self._stack.enter_context(some_resource())
        self._stack.callback(self._flush_logs)

    def shutdown(self):
        self._stack.close()  # runs all registered cleanups

Async Context Managers

Async context managers implement __aenter__ and __aexit__ (both coroutines) and are used with async with. They are essential for managing async resources like database connections, HTTP client sessions, and async locks. Use @asynccontextmanager from contextlib for the generator style.

import asyncio
from contextlib import asynccontextmanager
import aiohttp

@asynccontextmanager
async def http_session():
    """Managed aiohttp ClientSession — ensures proper cleanup."""
    async with aiohttp.ClientSession() as session:
        yield session

async def fetch_users():
    async with http_session() as session:
        async with session.get("https://api.example.com/users") as resp:
            return await resp.json()

# Async Timer
class AsyncTimer:
    async def __aenter__(self):
        self.start = asyncio.get_event_loop().time()
        return self

    async def __aexit__(self, *args):
        self.elapsed = asyncio.get_event_loop().time() - self.start
        print(f"Async block took {self.elapsed:.4f}s")

async def main():
    async with AsyncTimer() as t:
        await asyncio.sleep(0.1)
    print(f"Elapsed: {t.elapsed:.4f}s")

asyncio.run(main())

Real-World Patterns

Context managers shine in production code for locking, tracing, feature flags, and test fixtures. Here are patterns you'll encounter in real codebases.

import threading
from contextlib import contextmanager

# Thread-safe counter using a lock as context manager
class Counter:
    def __init__(self):
        self._value = 0
        self._lock = threading.Lock()

    def increment(self):
        with self._lock:
            self._value += 1

    @property
    def value(self):
        with self._lock:
            return self._value

# Tracing / observability span
@contextmanager
def trace_span(name, tags=None):
    import time
    start = time.perf_counter()
    try:
        yield
    except Exception as exc:
        print(f"SPAN ERROR [{name}]: {exc}")
        raise
    finally:
        elapsed_ms = (time.perf_counter() - start) * 1000
        print(f"SPAN [{name}] {elapsed_ms:.1f}ms tags={tags}")

with trace_span("db.query", tags={"table": "users"}):
    # database call here
    pass

# pytest fixture using contextmanager
@contextmanager
def fake_env(**kwargs):
    """Temporarily set environment variables in tests."""
    import os
    old = {k: os.environ.get(k) for k in kwargs}
    os.environ.update({k: str(v) for k, v in kwargs.items()})
    try:
        yield
    finally:
        for k, v in old.items():
            if v is None:
                os.environ.pop(k, None)
            else:
                os.environ[k] = v

with fake_env(DATABASE_URL="sqlite:///:memory:", DEBUG="true"):
    # test code that reads env vars
    pass

Frequently Asked Questions

Can __exit__ suppress exceptions?
Yes. If __exit__ returns a truthy value, the exception is suppressed and execution continues after the with block. contextlib.suppress works this way. Use sparingly — silent exception swallowing is usually a bug.
What happens if __enter__ raises?
If __enter__ raises, __exit__ is NOT called because the context was never successfully entered. The exception propagates normally.
Can I use a context manager without the with statement?
Yes, but don't do it in production. You can call obj.__enter__() and obj.__exit__(None, None, None) manually, but this bypasses the safety guarantee and is error-prone.
How is contextmanager different from a class?
Functionally identical. Use @contextmanager for simple cases where the generator style reads more naturally. Use a class when you need to store state, inherit, or reuse the context manager across multiple with blocks.