Skip to content
C Codeloom
Python

Python Context Managers and the with Statement

Learn how Python context managers work, how to write them with __enter__/__exit__ and contextlib, and how to use async context managers in real code.

·6 min read · By Yash Kesharwani
Intermediate 10 min read

What you'll learn

  • What context managers are and why with blocks exist
  • How to implement __enter__ and __exit__ on a class
  • How to use contextlib.contextmanager to write managers as generators
  • How to combine multiple context managers in one with statement
  • How async with and async context managers work

Prerequisites

If you have ever written with open("file.txt") as f: and wondered what exactly that with block is doing, this article is for you. Context managers are one of the cleanest tools Python gives you for managing resources. They make it almost impossible to forget cleanup, and they let you express setup/teardown logic in a way that reads top-to-bottom.

What a context manager actually is

A context manager is any object that defines two methods: __enter__ and __exit__. The with statement calls __enter__ when entering the block, binds whatever it returns to the as variable, and guarantees __exit__ runs when the block exits, including when an exception is raised.

That guarantee is the whole point. You do not have to remember to close a file, release a lock, or roll back a transaction. The context manager does it for you.

with open("notes.txt", "w") as f:
    f.write("hello")
# f is closed here, even if write() raised

Without with, the equivalent code is a try/finally:

f = open("notes.txt", "w")
try:
    f.write("hello")
finally:
    f.close()

The with form is shorter and harder to get wrong.

Writing your own context manager class

Any class with __enter__ and __exit__ will work. Here is a timer that prints how long a block took to run.

import time

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

    def __exit__(self, exc_type, exc_val, exc_tb):
        elapsed = time.perf_counter() - self.start
        print(f"took {elapsed:.4f}s")
        # return None (falsy) so exceptions propagate

with Timer() as t:
    sum(i * i for i in range(1_000_000))

A few things worth knowing about __exit__:

  • It receives the exception type, value, and traceback if the block raised. All three are None on a clean exit.
  • If __exit__ returns a truthy value, the exception is suppressed. Most of the time you want to return None and let it propagate.
  • __exit__ should not raise its own exception unless you really mean to.

Generators with contextlib.contextmanager

Writing a full class for a tiny resource feels heavy. The contextlib module ships a decorator that turns a generator into a context manager. The code before yield is __enter__, the code after is __exit__.

from contextlib import contextmanager

@contextmanager
def timer(label="block"):
    import time
    start = time.perf_counter()
    try:
        yield
    finally:
        print(f"{label} took {time.perf_counter() - start:.4f}s")

with timer("loop"):
    total = sum(range(10_000_000))

The try/finally around yield matters. If the with body raises, control returns to your generator at the yield and re-raises there. Without finally, your cleanup will be skipped on errors.

contextlib also gives you a few ready-made helpers worth knowing:

  • contextlib.suppress(SomeError) swallows specific exceptions inside a block.
  • contextlib.redirect_stdout(stream) reroutes prints temporarily.
  • contextlib.ExitStack() lets you stack a dynamic number of managers.

Multiple managers in one with

You can open several resources in a single with line by separating them with commas. They are entered left-to-right and exited right-to-left.

with open("in.txt") as src, open("out.txt", "w") as dst:
    for line in src:
        dst.write(line.upper())

If the list is dynamic, use ExitStack:

from contextlib import ExitStack

paths = ["a.txt", "b.txt", "c.txt"]
with ExitStack() as stack:
    files = [stack.enter_context(open(p)) for p in paths]
    for f in files:
        print(f.readline())

All files are closed when the with block exits, regardless of how many you opened.

Real-world uses beyond files

Context managers shine anywhere you have paired setup and teardown:

  • Database transactions: begin on enter, commit or rollback on exit. See error handling for how this pairs with exceptions.
  • Locks in threaded code: acquire on enter, release on exit.
  • Temporary directories: create on enter, remove on exit (tempfile.TemporaryDirectory).
  • Changing the working directory, environment variables, or logging level for the duration of a block.

Here is a small example that temporarily changes the working directory:

import os
from contextlib import contextmanager

@contextmanager
def cd(path):
    previous = os.getcwd()
    os.chdir(path)
    try:
        yield
    finally:
        os.chdir(previous)

with cd("/tmp"):
    print(os.getcwd())  # /tmp
print(os.getcwd())  # back to original

Async context managers

If you work with asyncio you will run into async with. The protocol is the same idea with two coroutine methods: __aenter__ and __aexit__. You enter and exit them with await.

import asyncio

class AsyncResource:
    async def __aenter__(self):
        print("opening")
        await asyncio.sleep(0)
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        print("closing")
        await asyncio.sleep(0)

async def main():
    async with AsyncResource() as r:
        print("using", r)

asyncio.run(main())

contextlib has an async equivalent of the generator pattern: @asynccontextmanager. Libraries like httpx, aiosqlite, and aiofiles rely on this protocol heavily, so getting comfortable with async with pays off the first time you write a real async script.

Common pitfalls

A few things trip people up:

  • Returning True from __exit__ accidentally. This silently swallows every exception in the block. Only do it if you really want suppression.
  • Forgetting try/finally in a @contextmanager generator. Cleanup will be skipped on exceptions.
  • Holding the manager open across await calls in async code when you did not mean to. Locks and DB connections do not love being parked there.
  • Treating with as a try/except replacement. It is for cleanup, not for catching errors. Pair it with proper exception handling when you need both.

When to reach for a context manager

The rule of thumb: if you have a resource or a piece of state that must be released, restored, or closed no matter what, wrap it in a context manager. The cost of writing one is small and the cost of forgetting cleanup somewhere in your code is large.

For more on writing reusable building blocks like this, see Python decorators, since decorators and context managers often appear in the same toolbox.

Wrap up

Context managers turn setup -> use -> teardown into a single readable block. Implement the protocol with __enter__/__exit__ on a class, or use @contextmanager for quick generator-based managers. Combine multiple managers on one line, or stack a dynamic list with ExitStack. For async code, the same idea applies with async with, __aenter__, and __aexit__. Once you start noticing the pattern, you will see places to use it everywhere: files, locks, transactions, timers, temporary state. Use them, and stop writing finally blocks by hand.