Python Context Managers Explained: The with Statement, contextlib and Real Uses
The with statement guarantees that setup is always matched by cleanup, even when something goes wrong. Learn how context managers work, how to write your own in two ways, and the surprises we found while testing them.
- A context manager pairs setup with cleanup. The
withstatement runs both, even when an exception occurs. - Write one as a class with
__enter__and__exit__, or as a generator with@contextlib.contextmanager. - In generator-based managers, put cleanup in
try/finally, or it is skipped on errors. We proved it. - Returning
Truefrom__exit__swallows the exception, so do it only on purpose.
Opening a file, taking a lock, starting a database transaction, changing a setting for a test: each of these has a matching “undo” that must happen afterwards. Forget it, or let an exception skip it, and you leak file handles, deadlock threads or leave the database half-updated. Python’s with statement exists to make that undo automatic.
We ran every example below with Python 3.13, and the outputs shown are from that run.
The problem context managers solve
Without with, safe cleanup needs a try/finally every time:
f = open("notes.txt", "w")
try:
f.write("hello")
finally:
f.close()
It works, but it is easy to forget, and the cleanup ends up far from the setup. The with version says the same thing in two lines:
with open("notes.txt", "w") as f:
f.write("hello")
We forced an error inside the block, with raise ValueError("boom"), then checked the file afterwards. The output was closed: True | content: hello. The file was closed and the data was saved, even though the block failed.
How the with statement works
Any object with two special methods can be used in a with statement:
- Python calls the object’s
__enter__()method. Whatever it returns is bound to the name afteras. - The indented block runs.
- Python calls
__exit__(exc_type, exc, tb), whether the block finished normally, returned early, or raised an exception.
That third step is the guarantee. It runs on return, break, continue and exceptions alike, which makes it more reliable than cleanup code written at the end of a block.
Writing a class-based context manager
import time
class Timer:
def __enter__(self):
self.start = time.perf_counter()
return self
def __exit__(self, exc_type, exc, tb):
self.elapsed = time.perf_counter() - self.start
print(f"took {self.elapsed:.3f}s, exception: {exc_type.__name__ if exc_type else None}")
return False
with Timer() as t:
time.sleep(0.05)
# took 0.050s, exception: None
__enter__ returns self, so the block and later code can read t.elapsed. The class form is best when the manager carries state or offers extra methods, such as a database session or a test server.
Exceptions and the return value of __exit__
__exit__ receives details of any exception, and its return value decides what happens next:
with Timer():
1 / 0
# took 0.000s, exception: ZeroDivisionError
# ZeroDivisionError still raised
Our Timer returned False, so the exception continued to the caller after the timing was printed. Returning True tells Python the exception has been handled:
class Swallow:
def __enter__(self): return self
def __exit__(self, *a): return True
with Swallow():
raise KeyError("hidden")
print("program continued")
The KeyError vanished completely. That is occasionally what you want, but usually it hides bugs. Note that returning None, which is what a method with no return does, counts as false and lets the exception through. That is the safe default. Our guide to Python exception handling explains why silently swallowing errors is dangerous.
The easy way: contextlib.contextmanager
For simple cases, a generator function with one yield is shorter than a class. Everything before yield is setup, the yielded value goes to as, and everything after is cleanup:
import contextlib, os
@contextlib.contextmanager
def temp_env(name, value):
old = os.environ.get(name)
os.environ[name] = value
try:
yield
finally:
if old is None:
del os.environ[name]
else:
os.environ[name] = old
with temp_env("APP_MODE", "test"):
print(os.environ["APP_MODE"]) # test
print(os.environ.get("APP_MODE")) # None
The variable existed inside the block and was removed afterwards, restoring the original state. This pattern is perfect for temporarily changing environment variables, the working directory or a global setting.
The missing finally trap
Here is the mistake almost everyone makes once:
@contextlib.contextmanager
def broken():
print("setup")
yield
print("cleanup (never runs on error)")
with broken():
raise RuntimeError("x")
# setup
# (cleanup skipped)
When the block raises, the exception is thrown into the generator at the yield line. Without try/finally, the code after yield never runs. Our cleanup line was never printed. Always wrap the yield in try/finally when cleanup must happen, as in temp_env above.
Tip: A good test for any context manager you write is to raise an exception inside the with block and check that the cleanup still happened. It takes two lines and catches exactly this bug.
Useful built-in context managers
| Manager | What it handles |
|---|---|
open() |
Closes the file |
threading.Lock() |
Acquires and releases the lock |
sqlite3.connect() |
Commits or rolls back a transaction |
tempfile.TemporaryDirectory() |
Deletes the folder afterwards |
contextlib.suppress(Error) |
Ignores one specific exception type |
contextlib.chdir(path) |
Changes directory and changes back (Python 3.11+) |
decimal.localcontext() |
Temporary decimal precision |
unittest.mock.patch() |
Temporary replacement in tests |
With a lock, our check printed locked: True inside the block and released: True after it. contextlib.suppress(FileNotFoundError) let us delete a file that might not exist without a try/except. Use suppress only for errors you truly expect and do not care about.
You can also open several managers in one statement, as in with open(a) as x, open(b) as y:. Our test read both files and closed both.
ExitStack: a variable number of managers
When you do not know in advance how many resources you need, ExitStack collects them and cleans up all of them at the end:
with contextlib.ExitStack() as stack:
files = [stack.enter_context(open(n, "w")) for n in names]
for i, fh in enumerate(files):
fh.write(str(i))
print(all(fh.closed for fh in files)) # True
All three files were closed. If opening the third had failed, the first two would still have been closed, which is hard to get right by hand. ExitStack also has a callback() method for registering any cleanup function.
Database transactions: a surprising detail
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE t (x INT)")
with conn:
conn.execute("INSERT INTO t VALUES (1)")
raise ValueError("abort")
# rows after error: 0
with conn:
conn.execute("INSERT INTO t VALUES (2)")
# rows after success: 1
A sqlite3 connection used in with commits if the block succeeds and rolls back if it fails, exactly what a transaction should do. But note what it does not do: the connection was still open afterwards. Unlike open(), the sqlite3 connection’s context manager manages the transaction, not the connection. Close it separately, for example with contextlib.closing(conn). Always check what a library’s context manager actually does; the name of the thing is not enough.
Async context managers
Asynchronous code uses async with and the methods __aenter__ and __aexit__, or @contextlib.asynccontextmanager. You will meet them with HTTP client sessions, database pools and asyncio.TaskGroup, which we cover in our asyncio guide.
Context managers in tests
Testing relies heavily on temporary changes that must be undone: patching functions, setting environment variables, creating temporary files. unittest.mock.patch works as a context manager, as shown in our mocking guide, and pytest’s raises is one too: with pytest.raises(ValueError): .... pytest fixtures with yield follow the same setup-yield-cleanup shape, described in our pytest fixtures guide.
Common mistakes
- No
try/finallyaroundyieldin a generator-based manager. - Returning
Truefrom__exit__by accident, which hides every exception. - Using the resource after the block. A file object still exists after
with, but it is closed. - Assuming what cleanup happens. As with sqlite3, read the documentation.
- Yielding more than once in a
@contextmanagerfunction, which raises aRuntimeError.
When to write your own context manager
Look for a pair of actions that must always happen together, where the second one is easy to forget. Good signs include:
- The same
try/finallyblock appears in several places. - Code changes some global state, such as a setting, a working directory or a feature flag, and must restore it.
- A resource is acquired from a pool and must be returned, such as a connection or a temporary account in a test environment.
- You want to time, trace or log a block of code consistently.
If the cleanup is a single call that already has a context manager, such as open() or a lock, use that instead of writing a new one. And if the “cleanup” needs data produced inside the block, a context manager may hide too much; a plain function with explicit steps can be clearer. The goal is fewer ways to forget cleanup, not cleverness.
Final thoughts
Context managers turn “remember to clean up” into a guarantee. Use with for every file, lock, connection and temporary change. When you write your own, choose a generator with try/finally for simple cases and a class when you need state, and test them by raising an exception inside the block.
Frequently asked questions
What is a context manager in Python?
It is an object that defines setup and cleanup through __enter__ and __exit__ methods. The with statement calls both, running cleanup even when an exception occurs.
How do I create a context manager?
Write a class with __enter__ and __exit__, or decorate a generator function with @contextlib.contextmanager and yield once, with cleanup in a finally block.
Does with close the file if an exception happens?
Yes. In our test, the file was closed and its content saved even though the block raised a ValueError.
What happens if __exit__ returns True?
The exception is suppressed and execution continues after the with block. Return False or None to let exceptions propagate.
Does with sqlite3.connect() close the connection?
No. It commits or rolls back the transaction, but the connection stays open. Close it separately or use contextlib.closing.
Keep learning
Need help with testing, WordPress or SEO?
We help teams ship better software and grow their sites. Tell us what you are working on and we will suggest a clear next step.