Community Article

Context Managers Beyond with open(...)

The `with` statement is a setup-teardown primitive, not a file-handling shortcut. Five context managers I write or use weekly, plus the gotcha that turns a class-based manager into a leak.

Context Managers Beyond with open(...)

The `with` statement is a setup-teardown primitive, not a file-handling shortcut. Five context managers I write or use weekly, plus the gotcha that turns a class-based manager into a leak.

py-context-managers
py-decorators
exceptions
fundamentals
interview-prep
yukisingh

By @yukisingh

April 5, 2026

·

Updated May 20, 2026

511 views

5

4.3 (12)

I have an embarrassing memory from early in my career: I knew with open(path) as f was the right way to read a file, but I thought that was the only thing with did. For three years I treated context managers as a file-handling idiom. Then a senior engineer reviewed a PR where I had a manual try/finally to release a database lock and asked, "why isn't this a with block?" I read the docs, wrote my first context manager that night, and have not looked at a paired setup/teardown the same way since.

The argument I want to make is that with is one of Python's best primitives, and almost every codebase under-uses it. Anywhere you have setup-then-teardown, especially teardown that has to run even on exceptions, a context manager is the cleanest shape Python offers. The protocol is small, the helpers are small, and the patterns are small. Once you have written three of them yourself, you stop writing try/finally by hand.

What the with statement actually does

A with block is syntactic sugar for a try/finally that calls two methods on the manager.

with ctx as value:
    body

# is sugar for, roughly:
value = ctx.__enter__()
try:
    body
finally:
    ctx.__exit__(exc_type, exc_value, traceback)

__enter__ runs at the top of the block and returns the value bound to the as clause. __exit__ runs at the end of the block, no matter how the block exits (normal return, exception, break, continue). It receives information about any exception that propagated out of the body. If __exit__ returns a truthy value, the exception is swallowed; if it returns falsy (or None), the exception propagates.

That is the entire protocol. Two methods, one return-value contract for swallowing exceptions, and the rest is your own logic.

A class-based context manager: timing a block of code

This is the first context manager I write when I want to debug a hot path. It is also the simplest possible non-trivial example.

import time

class Timer:
    def __init__(self, label):
        self.label = label

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

    def __exit__(self, exc_type, exc, tb):
        elapsed = time.perf_counter() - self.start
        print(f"{self.label}: {elapsed * 1000:.2f}ms")
        # return None / falsy = propagate exception, which is what we want

with Timer("db_query"):
    rows = run_query()

The class is straightforward: state on the instance, two methods. The pattern scales to anything where the setup and teardown share state (a transaction ID, an acquired lock handle, a started timer). Returning self from __enter__ is convenient if the block needs to call methods on the manager; returning a different value (an opened file handle, an acquired connection) is fine too.

The shortcut: @contextmanager

Most of the time I do not want a class. I want a generator function. The standard library provides exactly that with @contextmanager:

from contextlib import contextmanager
import time

@contextmanager
def timer(label):
    start = time.perf_counter()
    try:
        yield
    finally:
        elapsed = time.perf_counter() - start
        print(f"{label}: {elapsed * 1000:.2f}ms")

with timer("db_query"):
    rows = run_query()

The generator yields exactly once. Everything before the yield is __enter__. Everything after the yield (in the finally) is __exit__. Whatever you yield is bound to the as clause. The try/finally pattern in the generator handles exceptions exactly like the __exit__ method does in the class form.

For straightforward setup/teardown with no methods to expose, the generator form is shorter and reads better. I reach for it first; I drop down to the class form when the manager needs to expose methods or hold complex state.

Pattern 1: a database transaction

This is the second-most-common context manager in production Python after file handling. The shape is the same in every framework I have used.

from contextlib import contextmanager

@contextmanager
def transaction(conn):
    conn.execute("BEGIN")
    try:
        yield conn
        conn.execute("COMMIT")
    except Exception:
        conn.execute("ROLLBACK")
        raise

with transaction(conn) as tx:
    tx.execute("INSERT INTO users (...) VALUES (...)")
    tx.execute("INSERT INTO sessions (...) VALUES (...)")

The block commits on success, rolls back on any exception, and the caller does not have to remember to do either. Most ORMs ship something like this; writing the bare-DBAPI version once makes the framework versions feel less magical.

Note the raise after ROLLBACK. Without it, the exception would be silently swallowed, the transaction would be rolled back, and the caller would have no idea anything went wrong. Re-raising is almost always the right default; only swallow exceptions when you have a specific reason and you log them.

Pattern 2: temporary state changes

Anything that flips a flag, then needs to flip it back, is a context manager. Logging level overrides, environment variable patches, mock injection, frozen-time test scopes, all the same shape.

from contextlib import contextmanager
import logging

@contextmanager
def log_level(logger, level):
    previous = logger.level
    logger.setLevel(level)
    try:
        yield
    finally:
        logger.setLevel(previous)

with log_level(logging.getLogger(), logging.DEBUG):
    do_something_chatty()
# logger is back to its previous level here

The pattern: capture the old value, set the new value, yield, restore the old value in finally. Even with an exception in the body, the logger ends up restored. Any test framework's "with this patched in" helper is a context manager underneath; once you can write your own you can roll your own without dragging in the framework's machinery.

The real-world scenario where this earns its keep is targeted debugging in a noisy service. Last quarter I was chasing an intermittent payment-flow failure that happened maybe one in a thousand requests. I did not want to raise the global log level to DEBUG and drown the rest of the system in noise. I wrapped the suspect call site in with log_level(logging.getLogger('payments'), logging.DEBUG): and got verbose output for that path only, for the duration of the request, and the level snapped back automatically when the block exited. Even on the exception that I was actually trying to catch, the finally ran and the level was restored.

Nested context managers with different levels work the way you would hope, with one wrinkle: Python loggers form a tree, and propagate=True (the default) means a child logger's records bubble up to its parents. If you set getLogger('payments').level = DEBUG but the root logger is at WARNING, you may still see nothing, because the root's effective level wins on the parent handlers. The fix is usually to attach a handler at the level you actually care about, or to set propagate=False on the child and give it its own handler. The context manager is doing exactly what it advertises; the logging-tree semantics are the part that bites people. Knowing both lets you scope debug output surgically.

Pattern 3: suppressing a specific exception

The stdlib has contextlib.suppress for cases where you genuinely want to swallow a known exception type:

from contextlib import suppress

with suppress(FileNotFoundError):
    os.remove('/tmp/may-not-exist')

Writing the same thing as a try/except/pass is more typing and reads less clearly. The with suppress(...) form names the exception that is being ignored, in one line, at the top of the block. It is also a reminder to scope the suppression as tightly as possible; the block should contain only the operation whose failure you want to swallow.

When do I prefer suppress over a plain try/except? Three conditions: I am ignoring exactly one known exception type, I do not need the exception object for anything (no logging, no metrics, no fallback value), and the suppressed operation is one or two lines. The os.remove example fits all three. The moment I want a fallback ("if the file does not exist, log it and use a default"), or I want to ignore one type but raise another, the try/except form wins because it gives me an except Exc as e: binding and a clear else branch. I have seen suppress blocks grow from one line to ten as the team adds "just one more thing" inside them, and at that size they obscure which line was the one whose failure was actually expected. Keep suppress to the one-liner shape; reach for try/except the moment the logic gets richer.

A related helper is contextlib.nullcontext(), which is a context manager that does nothing. The classic use is to make a piece of code work the same whether or not a real context manager is in play. The test-vs-prod version looks like this:

from contextlib import nullcontext
import threading

_shared_lock = threading.Lock()

def write_audit_record(record, *, locked=True):
    cm = _shared_lock if locked else nullcontext()
    with cm:
        _audit_log.append(record)

In production we pass locked=True so the lock is acquired around the append; in unit tests where the audit log is single-threaded I pass locked=False, nullcontext() stands in for the lock, and the with cm: line stays unchanged. The same pattern works for optional database transactions, optional tracing spans, and any "sometimes I want to wrap this, sometimes I do not" decision. Without nullcontext, you end up with two branches of the same code, one inside a with and one not, drifting apart over time. The helper collapses them into one path.

Pattern 4: stacking multiple managers

The with statement supports multiple managers separated by commas, and they nest left-to-right.

with open('in.txt') as src, open('out.txt', 'w') as dst:
    dst.write(src.read())

For a fixed number of managers, this is the right shape. For a variable number (say, opening N files determined at runtime), use contextlib.ExitStack:

from contextlib import ExitStack

def merge_files(paths, out_path):
    with ExitStack() as stack:
        out = stack.enter_context(open(out_path, 'w'))
        srcs = [stack.enter_context(open(p)) for p in paths]
        for src in srcs:
            out.write(src.read())

ExitStack lets you push managers onto a stack at runtime; all of them get cleaned up in reverse order when the with ExitStack block exits. This is the pattern for "open a dynamic set of resources, clean them up regardless of how many succeed".

The class-based gotcha that costs people a day

A pattern that looks correct but leaks: returning the manager itself when the consumer expects the resource.

class DBSession:
    def __init__(self, dsn):
        self.dsn = dsn

    def __enter__(self):
        self.conn = connect(self.dsn)
        return self.conn

    def __exit__(self, exc_type, exc, tb):
        self.conn.close()

session = DBSession("...")
with session as conn:
    conn.execute("...")
# session.conn is now closed but still referenced
with session as conn:
    conn.execute("...")  # KABOOM: using a closed connection

If the manager instance is reused, the second with reassigns self.conn (so it works) only because of __enter__ being called again. But if __enter__ is not idempotent (because, say, it expects a clean state), the second use silently misbehaves.

My rule: a context manager instance is single-use unless you explicitly design it to be re-entrant. Make __enter__ defensive: assert that no resource is already held, or return a fresh instance from __enter__ to break the aliasing.

For most cases, the @contextmanager generator form sidesteps this entirely, because the generator object is consumed on use and a fresh one is created each time you call the function.

Where context managers shine, and where I do not bother

They shine when there is paired setup and teardown, especially when teardown has to run on exceptions. They do not earn their keep when there is no teardown, or when the setup/teardown is a single line.

  • Reach for one: file/network/db handles, locks, transactions, environment patches, timers, profilers, test fixtures, anything held during a block.
  • Skip it: simple variable assignments, one-liners that already use try/except cleanly, short scripts where a leak does not matter.

The thing I tell new Pythonists

The single best habit I picked up around with is that as soon as I write a try/finally more than three lines long, I ask whether the cleanup belongs in a context manager. About four times out of five it does, and the rewrite is shorter, easier to test, and reusable across the codebase. The with statement is one of the small Python features that pays back the time you spend learning it for the rest of your career, in code that does not leak resources and does not need a comment to explain why the cleanup is there. Read past the with open(...) cliche; the protocol is general, the use cases are everywhere, and once you see them you will start writing them in places you would not have considered before.

Back to Articles