Type something to search...
Context Managers in Python: with Statements and contextlib

Context Managers in Python: with Statements and contextlib

Most Python developers meet the with statement on day one, usually as with open("data.txt") as f:. It's easy to treat it as "the way you open files" and move on. But with is a general mechanism for pairing a setup step with a guaranteed cleanup step, and once you know how it works, you'll find a lot of code that gets shorter and safer by using it.

Files, locks, database transactions, temporary directories, timers, mocked settings in tests: they all share the same shape. Something has to happen before a block of code runs, and something else has to happen afterward, even if the block raises an exception.

In this post I'll cover what the with statement actually does, how to write context managers as classes with __enter__ and __exit__, how to write them as generators with @contextmanager, and the most useful helpers in the contextlib module, including ExitStack and async context managers.

The Problem with Solves

Here's the classic manual version of "open a file, use it, close it":

f = open("report.txt", "w")
try:
    f.write("quarterly numbers\n")
finally:
    f.close()

The try/finally is essential. Without it, an exception during write() would skip close() and leak the file handle. The trouble is that you have to remember to write that try/finally every single time, and nothing warns you when you forget.

The with statement packages the pattern into the object itself:

with open("report.txt", "w") as f:
    f.write("quarterly numbers\n")
# f is closed here, whether or not write() raised

The file object knows how to clean itself up, so callers can't forget. That's the core idea: the resource owns its cleanup logic, and with guarantees it runs.

How the with Statement Works

Any object that implements two special methods, __enter__ and __exit__, is a context manager. When Python runs:

with EXPRESSION as target:
    body

it does roughly this:

  1. Evaluates EXPRESSION to get the context manager.
  2. Calls its __enter__() method. Whatever that returns is bound to target (if you wrote as target).
  3. Runs body.
  4. Calls __exit__(exc_type, exc_value, traceback). If the body finished normally, all three arguments are None. If it raised, they describe the exception.
  5. If __exit__ returns a truthy value, the exception is suppressed. Otherwise it keeps propagating.

One detail that surprises people: the value bound by as is whatever __enter__ returns, not the context manager itself. For files, __enter__ returns self, so the two are the same. For other objects they may differ. A database connection's context manager might hand you a cursor, for instance.

The language reference on the with statement spells out the exact expansion if you want the full detail.

Writing a Class-Based Context Manager

Let's build a small timer that measures how long a block takes and reports whether it succeeded:

# timer.py
import time


class Timer:
    def __init__(self, label: str) -> None:
        self.label = label
        self.elapsed = 0.0

    def __enter__(self) -> "Timer":
        self._start = time.perf_counter()
        return self

    def __exit__(self, exc_type, exc, tb) -> bool:
        self.elapsed = time.perf_counter() - self._start
        status = "failed" if exc_type else "ok"
        print(f"{self.label}: {status}")
        return False  # don't swallow exceptions


with Timer("sum") as t:
    total = sum(range(1_000_000))
print(total, t.elapsed > 0)

try:
    with Timer("divide"):
        1 / 0
except ZeroDivisionError as e:
    print("caught:", e)

Output:

sum: ok
499999500000 True
divide: failed
caught: division by zero

A few things to notice:

  • __enter__ returns self, so as t gives you the timer, and you can read t.elapsed after the block.
  • __exit__ runs in both cases. In the second block it sees the ZeroDivisionError through exc_type, records the failure, and returns False so the exception still reaches the outer try.
  • The timer object outlives the with block. Leaving the block calls __exit__; it doesn't delete anything.

Suppressing Exceptions on Purpose

Returning True from __exit__ tells Python the exception has been handled. Here's a tiny context manager that swallows specific exception types:

class Suppress:
    def __init__(self, *exceptions: type[BaseException]) -> None:
        self.exceptions = exceptions

    def __enter__(self) -> None:
        return None

    def __exit__(self, exc_type, exc, tb) -> bool:
        return exc_type is not None and issubclass(exc_type, self.exceptions)


with Suppress(KeyError):
    {}["missing"]
print("still running")

This prints still running. Suppression is powerful and easy to abuse. A context manager that returns True unconditionally will silently hide bugs, so only suppress exceptions you've specifically decided are safe to ignore. In practice you don't need to write this class at all, because contextlib.suppress already does it (more on that below).

When to Use the Class Form

The class form is the right choice when:

  • The context manager carries state that callers want after the block (like Timer.elapsed).
  • The object is useful on its own and with support is just one feature, as with files, locks, and connections.
  • You need the context manager to be reusable, entered more than once.

For one-off setup and teardown logic, the generator form is usually shorter.

Generator-Based Context Managers with @contextmanager

The contextlib.contextmanager decorator turns a generator function into a context manager. Everything before the yield is the setup, the yielded value is what as binds to, and everything after the yield is the teardown.

# workdir.py
import os
from collections.abc import Iterator
from contextlib import contextmanager


@contextmanager
def working_directory(path: str) -> Iterator[str]:
    previous = os.getcwd()
    os.chdir(path)
    try:
        yield path
    finally:
        os.chdir(previous)


start = os.getcwd()
with working_directory("/tmp") as p:
    print("inside:", p)
print("restored:", os.getcwd() == start)

The try/finally around the yield is the part people forget. If the body of the with block raises, @contextmanager re-raises that exception at the yield point inside your generator. Without finally, the line that restores the old directory would never run.

If generators are new to you, What Is a Generator in Python? covers how yield pauses and resumes a function, which is exactly the mechanism @contextmanager relies on.

Reacting to Success and Failure Differently

Because the body's exception surfaces at the yield, you can use try/except/else to run different cleanup depending on the outcome. A transaction is the textbook example:

# transaction.py
from collections.abc import Iterator
from contextlib import contextmanager


@contextmanager
def transaction(log: list[str]) -> Iterator[None]:
    log.append("BEGIN")
    try:
        yield
    except Exception:
        log.append("ROLLBACK")
        raise
    else:
        log.append("COMMIT")


log: list[str] = []
with transaction(log):
    log.append("insert user")

try:
    with transaction(log):
        log.append("insert order")
        raise ValueError("bad total")
except ValueError as e:
    log.append(f"error: {e}")

print(log)

Output:

['BEGIN', 'insert user', 'COMMIT', 'BEGIN', 'insert order', 'ROLLBACK', 'error: bad total']

The bare raise in the except block matters. If you leave it out, the generator finishes normally, and @contextmanager treats that as "exception handled", so the ValueError would be swallowed. Re-raise unless you genuinely mean to suppress.

A real database library would issue COMMIT and ROLLBACK against a connection, but the structure is identical. Python's built-in sqlite3 connections already work this way when used in a with block.

Rules for @contextmanager Generators

  • Yield exactly once. Yielding zero times or twice raises a RuntimeError.
  • Wrap the yield in try/finally (or try/except/else) if teardown must always run.
  • Re-raise exceptions you catch, unless suppression is the goal.
  • The returned object is single-use. Calling working_directory("/tmp") gives you a fresh context manager each time, which is fine, but you can't enter the same returned object twice.

Using Context Managers as Decorators

Sometimes you want an entire function to run inside a context. Context managers created with @contextmanager can also be used as decorators, because the helper class they return inherits from contextlib.ContextDecorator:

from contextlib import contextmanager


@contextmanager
def tag(name: str):
    print(f"<{name}>")
    yield
    print(f"</{name}>")


@tag("p")
def hello() -> None:
    print("hello")


hello()

Output:

<p>
hello
</p>

For a class-based context manager, inherit from ContextDecorator to get the same ability:

import time
from contextlib import ContextDecorator


class timed(ContextDecorator):
    def __init__(self, label: str) -> None:
        self.label = label

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

    def __exit__(self, *exc) -> bool:
        print(f"{self.label} took {time.perf_counter() - self.start:.3f}s")
        return False


@timed("report")
def build_report() -> None:
    time.sleep(0.05)


build_report()  # prints something like: report took 0.051s

One limitation: when used as a decorator, the function body can't access the value from __enter__, because there's no as clause. If the function needs that value, use a regular with block inside it.

Useful Tools in contextlib

The standard library ships several ready-made context managers. Before writing your own, check whether one of these already does the job.

suppress: Ignore Specific Exceptions

import os
from contextlib import suppress

with suppress(FileNotFoundError):
    os.remove("does-not-exist.txt")

This replaces a try/except FileNotFoundError: pass block. It reads well for "delete this if it exists" style operations. Keep the list of exceptions narrow, and keep the body short, since any line in the block that raises one of those exceptions will be silently skipped from that point on.

closing: Add with Support to Objects That Only Have close()

Some objects have a close() method but don't implement the context manager protocol. closing wraps them:

from contextlib import closing


class Connection:
    def close(self) -> None:
        print("connection closed")


with closing(Connection()) as conn:
    print("using", type(conn).__name__)

Output:

using Connection
connection closed

redirect_stdout and redirect_stderr: Capture Printed Output

Useful when you're calling code that prints instead of returning values:

import io
from contextlib import redirect_stdout

buffer = io.StringIO()
with redirect_stdout(buffer):
    print("captured")
print(repr(buffer.getvalue()))  # 'captured\n'

These swap out sys.stdout globally for the duration of the block, so they aren't safe to use from multiple threads at once. They're best suited to scripts and tests.

nullcontext: A Context Manager That Does Nothing

nullcontext is handy when a with block is sometimes needed and sometimes not. Instead of duplicating the body, you choose the context manager up front:

import io
from contextlib import nullcontext


def process(path: str | None) -> str:
    cm = open(path) if path else nullcontext(io.StringIO("default data"))
    with cm as f:
        return f.read()


print(process(None))  # default data

nullcontext(value) simply returns value from __enter__ and does nothing on exit. Here, the file is closed by with when a path is given, and the in-memory default is used as-is otherwise.

chdir: Temporarily Change Directory

The working_directory example earlier was a teaching example. Since Python 3.11, contextlib.chdir does the same thing:

from contextlib import chdir

with chdir("/tmp"):
    ...  # relative paths resolve against /tmp here
# back to the original directory

Like redirect_stdout, it changes process-wide state, so avoid it in multithreaded code.

Multiple Context Managers in One with

You can enter several context managers in one statement. They're entered left to right and exited in reverse order:

with open("input.txt") as src, open("output.txt", "w") as dst:
    dst.write(src.read())

When the line gets long, Python 3.10 and later let you wrap them in parentheses:

with (
    open("input.txt") as src,
    open("output.txt", "w") as dst,
):
    dst.write(src.read())

If opening output.txt fails, src is still closed properly, because it was already entered.

ExitStack: A Dynamic Number of Context Managers

The syntax above works when you know the number of context managers when you write the code. When you don't, for example when opening every file in a list, use ExitStack:

# merge_files.py
import tempfile
from contextlib import ExitStack
from pathlib import Path

tmp = Path(tempfile.mkdtemp())
names = ["a.txt", "b.txt", "c.txt"]
for n in names:
    (tmp / n).write_text(f"line from {n}\n")

with ExitStack() as stack:
    files = [stack.enter_context(open(tmp / n)) for n in names]
    for f in files:
        print(f.readline().strip())

print(all(f.closed for f in files))

Output:

line from a.txt
line from b.txt
line from c.txt
True

enter_context() enters a context manager and registers its exit with the stack. When the with ExitStack() block ends, every registered exit runs in reverse order, even if one of them raises. If the third open() fails, the first two files are still closed.

ExitStack can also register plain callbacks, which is useful for cleanup that doesn't come wrapped in a context manager:

from contextlib import ExitStack

with ExitStack() as stack:
    stack.callback(print, "cleanup 1")
    stack.callback(print, "cleanup 2")
    print("body")

Output:

body
cleanup 2
cleanup 1

Note the reverse order: last registered, first cleaned up. That's the same order you'd get from nested with blocks, and it's usually what you want, since later resources often depend on earlier ones.

Async Context Managers

Async code has its own version of the protocol: __aenter__ and __aexit__, used with async with. These are coroutines, so setup and teardown can await things like network connections.

The generator shortcut exists too, as @asynccontextmanager:

# pool.py
import asyncio
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager


class Pool:
    async def open(self) -> None:
        print("pool opened")

    async def close(self) -> None:
        print("pool closed")


@asynccontextmanager
async def lifespan_pool() -> AsyncIterator[Pool]:
    pool = Pool()
    await pool.open()
    try:
        yield pool
    finally:
        await pool.close()


async def main() -> None:
    async with lifespan_pool() as pool:
        print("using", type(pool).__name__)


asyncio.run(main())

Output:

pool opened
using Pool
pool closed

This exact pattern shows up in web frameworks. FastAPI's lifespan parameter, for example, takes an async context manager to open and close shared resources when the app starts and stops. contextlib also has AsyncExitStack for a dynamic number of async context managers. For the bigger picture on async/await, see Asyncio in Python: A Beginner's Guide to Asynchronous Programming.

Context Managers You Already Use

A quick tour of standard-library objects that support with, so you can recognize the pattern:

ObjectWhat with guarantees
open() file objectsFile is closed
threading.LockLock is released
sqlite3.ConnectionTransaction is committed or rolled back (the connection stays open)
tempfile.TemporaryDirectory()Directory and contents are deleted
decimal.localcontext()Previous decimal precision is restored
unittest.mock.patch()Original object is restored
concurrent.futures.ThreadPoolExecutorExecutor shuts down and waits for work

The sqlite3 row catches people out. Exiting the with block on a connection ends the transaction, but it doesn't close the connection. If you want both, wrap it in closing(), or close it explicitly.

Common Mistakes

  • Forgetting try/finally around yield. Your teardown runs on success and silently doesn't run on failure. This is the most common bug in @contextmanager code.
  • Returning True from __exit__ by accident. Any truthy return value suppresses the exception. Return False or None unless you mean it.
  • Using the resource after the block. Code after the with block can still reference the variable, but the file is closed or the lock is released. Reading from a closed file raises ValueError: I/O operation on closed file.
  • Doing too much inside one block. Keep the body of a with block to the work that actually needs the resource. Holding a lock while doing slow, unrelated work makes everything else wait.

Conclusion

A context manager is an object with __enter__ and __exit__, and the with statement guarantees that __exit__ runs no matter how the block ends. That small contract replaces a lot of repetitive and error-prone try/finally code.

Write a class when the context manager carries state or is reusable. Write a generator with @contextmanager for quick setup-and-teardown logic, and always wrap the yield in try/finally. Before writing either, check contextlib: suppress, closing, redirect_stdout, nullcontext, chdir, and ExitStack cover a surprising number of everyday needs. Error handling and context managers go hand in hand, so if you want to go deeper on the exception side, read Exception Handling in Python: try, except, else, and finally.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading