Context Managers in Python (with, contextlib)

The earlier with statement article covered the __enter__/__exit__ protocol and the @contextmanager basics. This article goes further — treating python contextlib module as a full toolbox: ContextDecorator, suppress, ExitStack, nullcontext, and the async equivalents, covered in genuine depth rather than repeating the fundamentals already established.

Introduction: contextlib as the Context Manager Toolbox

A quick recap

As covered in the earlier with statement article, with relies on the __enter__/__exit__ protocol — Python calls __enter__() when a block starts, and guarantees __exit__() runs when it ends, regardless of how. contextlib is the standard-library module full of utilities that help you write and manipulate context managers, well beyond just the basic @contextmanager decorator already introduced.

What this article covers

This article goes beyond the basics into the ready-made helpers that mean a custom class isn't always necessary: @contextmanager's lesser-known ability to double as a function decorator, suppress(), closing(), nullcontext(), ExitStack for dynamic resource management, and the async equivalents for use with async with.

@contextmanager Context Managers Can Also Be Decorators

The mechanism

Here's a genuinely underused capability: contextlib.contextmanager() internally uses a mixin class called ContextDecorator. This means any context manager built with @contextlib.contextmanager can be applied directly to a function as a decorator, not just used inside a with block — no extra code required to get this second capability.

from contextlib import contextmanager
import time

@contextmanager
def timeit():
    start = time.time()
    yield
    print(f"Elapsed: {time.time() - start:.4f} seconds")
Two call styles, same underlying logic
# Style 1 — as a context manager, wrapping a block
with timeit():
    total = sum(range(1_000_000))

# Style 2 — as a decorator, wrapping an entire function
@timeit()
def slow_function():
    total = sum(range(1_000_000))
    return total

slow_function()

Both produce identical timing behavior — the exact same timeit() definition works as either a with-block wrapper or a function decorator, without writing any additional code to support the second usage. This is genuinely convenient: write your setup/teardown logic once, and get both calling conventions for free.

A custom ContextDecorator subclass for class-based context managers

If you've written a full, class-based context manager (rather than the generator-based @contextmanager shortcut), you can get the same decorator capability by explicitly inheriting from ContextDecorator:

from contextlib import ContextDecorator

class Timer(ContextDecorator):
    def __enter__(self):
        import time
        self.start = time.time()
        return self

    def __exit__(self, *exc_info):
        import time
        print(f"Elapsed: {time.time() - self.start:.4f} seconds")
        return False

# Works as a context manager
with Timer():
    total = sum(range(1_000_000))

# ALSO works as a decorator, purely because it inherits from ContextDecorator
@Timer()
def slow_function():
    return sum(range(1_000_000))

Inheriting from ContextDecorator is all that's needed — no additional methods to implement — to give a class-based context manager this same dual capability, mirroring exactly what @contextmanager already provides automatically for the generator-based style.

Suppressing and Cleaning Up: suppress, closing, nullcontext

contextlib.suppress: cleaner than try/except/pass
from contextlib import suppress

with suppress(FileNotFoundError):
    import os
    os.remove("maybe_exists.txt")

This is functionally equivalent to:

try:
    os.remove("maybe_exists.txt")
except FileNotFoundError:
    pass

suppress() states the intent more directly — "deliberately ignore this specific, expected exception" is immediately clear from the line itself, rather than requiring a reader to notice an empty except block and infer the omission was intentional. It also accepts multiple exception types at once: suppress(FileNotFoundError, PermissionError).

contextlib.closing: giving with support to objects that lack it

Some objects — particularly older or third-party ones — have a .close() method but never implemented the full __enter__/__exit__ protocol themselves, meaning they can't be used directly in a with statement. closing() wraps any such object, guaranteeing .close() is called on block exit:

from contextlib import closing
from urllib.request import urlopen

with closing(urlopen("https://example.com")) as page:
    c page.read()
# page.close() is called automatically here, even though urlopen()'s
# result doesn't implement __enter__/__exit__ on its own

This is genuinely practical for legacy or third-party objects you don't control the implementation of — closing() retrofits with-compatible cleanup behavior onto anything with a .close() method, without needing to modify the original object's class at all.

contextlib.nullcontext: a do-nothing placeholder

nullcontext() is a context manager that does absolutely nothing — no setup, no teardown — but it satisfies the with protocol, which makes it genuinely useful in one specific, common situation: when your code conditionally needs a context manager, or doesn't, without wanting to branch the entire with statement itself into two separate code paths.

from contextlib import nullcontext
import threading

def process(data, lock=None):
    c lock if lock is not None else nullcontext()
    with context:
        # process data — genuinely locked if a real lock was provided,
        # or a harmless no-op if it wasn't
        return sum(data)

Without nullcontext(), you'd need something considerably clunkier — an if lock is not None: with lock: ... else: ... branch, duplicating the actual processing logic across both branches. nullcontext() lets a single with context: line handle both the "real lock" and "no lock needed" cases uniformly, with the actual processing logic written exactly once.

Managing Multiple or Dynamic Resources with ExitStack

The problem it solves

Everything covered in the earlier with statement article's parenthesized multiple-context-manager syntax works well when you know exactly how many resources you need, at the time you write the code. But sometimes the number of resources genuinely isn't known in advance — a variable-length list of files, or resources entered conditionally based on runtime logic — situations a fixed with (a, b, c): statement simply can't express.

How it works
from contextlib import ExitStack

filenames = ["a.txt", "b.txt", "c.txt"]

with ExitStack() as stack:
    files = [stack.enter_context(open(name)) for name in filenames]
    for file in files:
        print(file.read())
# every file is correctly closed here, regardless of how many there were

stack.enter_context(...) registers each context manager with the stack as it's entered. When the ExitStack itself exits, every registered context is cleaned up automatically, in LIFO order (last registered, first cleaned up) — mirroring exactly how nested with statements would unwind if you'd written them explicitly, one inside another.

A practical example: conditionally including optional resources
from contextlib import ExitStack

def process_files(filenames, use_lock=False, lock=None):
    with ExitStack() as stack:
        if use_lock and lock is not None:
            stack.enter_context(lock)   # conditionally registered
        files = [stack.enter_context(open(name)) for name in filenames]
        for file in files:
            print(file.read())

This handles a genuinely dynamic combination of resources cleanly: an optional lock, plus a variable-length list of files, all correctly cleaned up together regardless of exactly how many resources ended up being registered on any given call — something a fixed, hardcoded with statement genuinely can't express, since it requires knowing the exact resource list at the time the code is written.

An important gotcha

Reusing a single ExitStack instance across multiple, separate, sequential with statements works correctly:

stack = ExitStack()

with stack:
    stack.enter_context(open("a.txt"))
# stack is now closed, and empty

with stack:
    stack.enter_context(open("b.txt"))
# works fine — the stack was properly reset when the first 'with' exited

But nesting the same ExitStack instance inside itself, or reusing it in a way that overlaps rather than being strictly sequential, clears it prematurely, since entering a with block on an already-active stack resets its internal state. If you need genuinely nested scopes — one ExitStack inside another — use separate instances for each nesting level, rather than attempting to reuse a single instance across overlapping scopes.

Async Context Managers: asynccontextmanager and Beyond

The async with equivalent

For asynchronous code (covered in more depth in a later article in this series on asyncio), the async with statement relies on __aenter__/__aexit__ instead of __enter__/__exit__ — the async counterparts, both of which must be defined as async def methods, since they need to be awaited.

class AsyncDatabaseConnection:
    async def __aenter__(self):
        print("Acquiring connection...")
        # await some_async_connect_call()
        return self

    async def __aexit__(self, exc_type, exc_value, traceback):
        print("Releasing connection...")
        # await some_async_close_call()
        return False

async def main():
    async with AsyncDatabaseConnection() as conn:
        print("Using connection")
contextlib.asynccontextmanager: the generator-based async shortcut

Exactly mirroring @contextmanager's generator-based shortcut for synchronous code, @contextlib.asynccontextmanager offers the same convenience for async with, built around an async def generator function:

from contextlib import asynccontextmanager

@asynccontextmanager
async def database_connection():
    print("Acquiring connection...")
    # c await some_async_connect_call()
    try:
        yield "connection"   # this becomes the 'as' value
    finally:
        print("Releasing connection...")
        # await conn.close()

async def main():
    async with database_connection() as conn:
        print(f"Using {conn}")

Everything before yield runs as the async setup logic; everything after runs as async cleanup — identical structure to the synchronous @contextmanager pattern, just with async def and await where appropriate for genuinely asynchronous setup and teardown operations.

As a decorator, since Python 3.10

Since Python 3.10, @asynccontextmanager-created context managers gained the same dual capability covered in Section 2 for the synchronous case — usable directly as a decorator on an async def function, not just inside async with:

@database_connection()
async def fetch_user_data(user_id):
    # runs with the connection automatically acquired and released around it
    pass
Quick closing guidance

As a default, reach for the decorator-based generator style — @contextmanager or @asynccontextmanager — for both synchronous and asynchronous cases. It's more concise, requires no class boilerplate, and (as covered throughout this article) gives you decorator capability essentially for free, alongside standard with/async with usage. Reserve full, explicit classes (__enter__/__exit__ or __aenter__/__aexit__) for context managers that genuinely need additional methods, more complex internal state tracked across multiple operations, or fine-grained control over exception handling beyond what a simple try/finally wrapped around a single yield comfortably expresses — exactly the same guidance given in the earlier with statement article, now extended consistently to the async case as well.