Context Managers

Guaranteed setup and teardown — the Pythonic way to manage any resource

Why

Context Managers are how Python guarantees resource cleanup even when exceptions occur. Open files, database connections, locks, GPU memory, network sessions, temp directories — all should be managed with with. Without it, a single exception leaves resources leaked.

Writing your own context managers unlocks patterns that appear everywhere in production code: database transaction rollback, profiling blocks, suppressing specific exceptions, and async resource management. The protocol is small but the design implications are large — a good mental model for how Python handles resource lifetimes.

Intuition

The with statement is a guaranteed try/finally with a cleaner name and a reusable protocol. It calls __enter__ on entry (setup, returns the resource) and __exit__ on exit (cleanup) — regardless of whether the block raised an exception.

Think of it as a contract: "I promise to clean up after myself, even if something goes wrong inside." The context manager holds that promise so every call site doesn't have to remember to.

Explanation
1. The protocol — __enter__ and __exit__

The context manager protocol is two methods: __enter__ for setup and __exit__ for teardown. Python calls them automatically around the with block — even if an exception is raised.

class ManagedResource: def __enter__(self): # setup — acquire the resource self.resource = acquire() return self.resource # value bound to the 'as' variable def __exit__(self, exc_type, exc_val, exc_tb): # cleanup — always called, even on exception release(self.resource) # return True → suppress the exception # return False/None → let it propagate (almost always this) return False with ManagedResource() as r: use(r) # equivalent to: # mgr = ManagedResource() # r = mgr.__enter__() # try: # use(r) # finally: # mgr.__exit__(*sys.exc_info())

__enter__ runs setup and returns the value bound to the as variable. __exit__ always runs — it receives exception info (or None) and returns False/None to let exceptions propagate, True to suppress them.

2. @contextmanager — the simple way

Writing a full class just for a context manager is verbose. The @contextmanager decorator turns a generator function into one — code before yield is setup, code in finally is guaranteed teardown.

from contextlib import contextmanager @contextmanager def managed_transaction(conn): try: yield conn.cursor() # code inside the with block runs here conn.commit() # only reached if no exception except Exception: conn.rollback() # on any exception, roll back raise # re-raise so caller knows it failed with managed_transaction(conn) as cursor: cursor.execute("INSERT INTO ...") # Timing blocks @contextmanager def timer(label): import time start = time.perf_counter() try: yield finally: print(f"{label}: {time.perf_counter() - start:.3f}s") with timer("data load"): df = pd.read_parquet("data.parquet")

Code before yield is __enter__. Code in finally after yield is __exit__. Much less boilerplate than writing a full class. The try/finally ensures cleanup even if the block raises.

3. contextlib utilities

The contextlib module ships ready-made helpers for common context manager patterns — suppressing specific exceptions, optional managers, and managing a dynamic number of resources.

from contextlib import suppress, nullcontext, ExitStack # suppress — swallow specific exceptions cleanly with suppress(FileNotFoundError): os.remove("temp.txt") # no error if file doesn't exist # nullcontext — placeholder when context may or may not exist def process(data, lock=None): with lock if lock is not None else nullcontext(): do_work(data) # ExitStack — dynamic number of context managers with ExitStack() as stack: files = [stack.enter_context(open(f)) for f in file_list] # all files closed on exit, even if some failed to open

suppress is a clean alternative to a bare try/except: pass. nullcontext lets code optionally use a context manager without duplicating the inner block. ExitStack manages a variable number of resources opened at runtime.

4. Async context managers

Async context managers work identically to sync ones but use async with — needed when setup or teardown involves awaitable IO like opening a database connection or HTTP session.

from contextlib import asynccontextmanager @asynccontextmanager async def db_session(dsn): conn = await asyncpg.connect(dsn) try: yield conn finally: await conn.close() # guaranteed even on exception async with db_session(dsn) as conn: await conn.execute("SELECT 1") # Class-based async context manager class AsyncSession: async def __aenter__(self): self.conn = await connect() return self.conn async def __aexit__(self, *args): await self.conn.close()

Async context managers use async with and implement __aenter__ / __aexit__ (both async). The @asynccontextmanager decorator works the same as its sync equivalent but with async def and await inside.