
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:
- Evaluates
EXPRESSIONto get the context manager. - Calls its
__enter__()method. Whatever that returns is bound totarget(if you wroteas target). - Runs
body. - Calls
__exit__(exc_type, exc_value, traceback). If the body finished normally, all three arguments areNone. If it raised, they describe the exception. - 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__returnsself, soas tgives you the timer, and you can readt.elapsedafter the block.__exit__runs in both cases. In the second block it sees theZeroDivisionErrorthroughexc_type, records the failure, and returnsFalseso the exception still reaches the outertry.- The timer object outlives the
withblock. 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
withsupport 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
yieldintry/finally(ortry/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:
| Object | What with guarantees |
|---|---|
open() file objects | File is closed |
threading.Lock | Lock is released |
sqlite3.Connection | Transaction 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.ThreadPoolExecutor | Executor 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/finallyaroundyield. Your teardown runs on success and silently doesn't run on failure. This is the most common bug in@contextmanagercode. - Returning
Truefrom__exit__by accident. Any truthy return value suppresses the exception. ReturnFalseorNoneunless you mean it. - Using the resource after the block. Code after the
withblock can still reference the variable, but the file is closed or the lock is released. Reading from a closed file raisesValueError: I/O operation on closed file. - Doing too much inside one block. Keep the body of a
withblock 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.


