Python's with statement is one of the language's most elegant features. It handles resource setup and teardown automatically, making your code cleaner, safer, and more readable. At its core sits the context manager protocol — an interface that any object can implement to participate in this pattern.
In this tutorial, you will learn what context managers are, how to build custom ones, and when to reach for contextlib utilities instead. By the end, you should be comfortable writing your own and refactoring existing code to use them.
What Is a Context Manager?
A context manager is any object that defines two magic methods: __enter__ and __exit__. When you use the with keyword, Python calls __enter__ before the block runs and __exit__ after it finishes — even if an exception occurs.
with open("data.txt", "r") as f:
content = f.read()
Here open() returns a file object that is also a context manager. Its __enter__ returns the file handle, and its __exit__ calls f.close() regardless of how the block exits. Without with, you would need explicit try/finally:
f = open("data.txt", "r")
try:
content = f.read()
finally:
f.close()
The with version is shorter and eliminates the risk of forgetting to close the file.
The Protocol: __enter__ and __exit__
Every context manager follows this contract:
| Method | Signature | Purpose |
|---|---|---|
__enter__ |
(self) → Any |
Acquire resource, return value bound to as target |
__exit__ |
(self, exc_type, exc_val, exc_tb) → bool |
Release resource; return True to suppress an exception |
The __exit__ method receives three arguments when an exception propagates out of the with block:
-
exc_type— the exception class (e.g.ValueError) -
exc_val— the exception instance -
exc_tb— the traceback object
If the block completes normally, all three are None.
Building a Custom Context Manager
Imagine you manage a fleet of Linux servers and need to acquire an SSH connection, run a command, then close it reliably. Here is a minimal context manager that wraps the paramiko SSH client:
import paramiko
class SSHConnection:
def __init__(self, host, username, key_path):
self.host = host
self.username = username
self.key_path = key_path
self.client = None
def __enter__(self):
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self.client.connect(
self.host,
username=self.username,
key_filename=self.key_path,
)
return self.client
def __exit__(self, exc_type, exc_val, exc_tb):
if self.client:
self.client.close()
# Return False so exceptions propagate normally
return False
Usage:
with SSHConnection("192.168.1.100", "deploy", "/home/deploy/.ssh/id_rsa") as ssh:
stdin, stdout, stderr = ssh.exec_command("uptime")
print(stdout.read().decode())
The connection is guaranteed to close, even if exec_command raises an exception.
When Should __exit__ Return True?
By default __exit__ returns None (falsy), so exceptions bubble up. If you return True, the exception is swallowed and execution continues after the with block. Use this sparingly — it can mask bugs.
One legitimate use case is ignoring specific transient errors:
class IgnoreFileNotFound:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is FileNotFoundError:
print("File not found — continuing anyway")
return True
return False
A warning: suppressing exceptions makes debugging harder. Prefer explicit try/except inside the with block for most cases.
The contextlib Module
Python's standard library provides contextlib — a collection of helpers that reduce boilerplate when writing context managers.
@contextmanager Decorator
This is the most popular approach. Decorate a generator function that has a single yield. Everything before yield is __enter__; everything after is __exit__:
from contextlib import contextmanager
import os
@contextmanager
def change_directory(path):
"""Temporarily change working directory."""
old_cwd = os.getcwd()
os.chdir(path)
try:
yield
finally:
os.chdir(old_cwd)
Usage:
with change_directory("/var/log"):
for f in os.listdir("."):
if f.endswith(".log"):
print(f" {f}")
# Back to original directory automatically
The try/finally inside the generator ensures the directory is restored even if the block raises an exception — exactly like __exit__.
closing() for Objects With a close() Method
Some objects have a close() method but are not context managers. Wrap them with contextlib.closing:
from contextlib import closing
import urllib.request
with closing(urllib.request.urlopen("https://example.com")) as resp:
data = resp.read()
This calls resp.close() on exit without needing to write a custom class.
suppress() for Expected Exceptions
Replace boilerplate try/except pass with suppress:
from contextlib import suppress
import shutil
with suppress(FileNotFoundError):
shutil.rmtree("/tmp/build_cache")
This is equivalent to:
try:
shutil.rmtree("/tmp/build_cache")
except FileNotFoundError:
pass
redirect_stdout and redirect_stderr
Temporarily capture standard output or error streams — useful for testing or silencing noisy libraries:
from contextlib import redirect_stdout
import io
buf = io.StringIO()
with redirect_stdout(buf):
print("This goes into the buffer, not the console")
output = buf.getvalue()
nullcontext — A No-Op Context Manager
When your code needs to conditionally apply a context manager, nullcontext acts as a placeholder that does nothing:
from contextlib import nullcontext
import os
maybe_file = open("log.txt", "w") if os.getenv("DEBUG") else nullcontext()
with maybe_file as f:
if f:
f.write("Debug output")
This avoids duplicating the with block for the two code paths.
ExitStack for Dynamic Resources
When you do not know ahead of time how many context managers you need, use ExitStack:
from contextlib import ExitStack
config_files = ["app.cfg", "db.cfg", "cache.cfg"]
with ExitStack() as stack:
handles = [
stack.enter_context(open(f, "r"))
for f in config_files
]
# All files are open; read and merge configuration
# All files closed atomically when the `with` block exits
ExitStack also works with callbacks via stack.callback(), making it useful for managing arbitrary teardown logic.
Practical Example: Database Transaction Manager
A common real-world pattern is managing database transactions. Context managers make it easy to commit on success and roll back on failure:
import sqlite3
from contextlib import contextmanager
@contextmanager
def transaction(db_path):
"""Wrap a SQLite connection in a transaction."""
conn = sqlite3.connect(db_path)
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
Usage:
with transaction("inventory.db") as conn:
conn.execute("UPDATE products SET stock = stock - 1 WHERE id = ?", (42,))
conn.execute("INSERT INTO orders (product_id) VALUES (?)", (42,))
If either statement fails, the transaction rolls back and the database remains consistent. No manual commit or rollback calls — the context manager handles everything.
Nesting Context Managers
You can nest with statements using commas (Python 3.1+):
with open("src.txt", "r") as src, open("dst.txt", "w") as dst:
dst.write(src.read())
This is equivalent to nested with blocks and ensures both files close properly, even if src.read() raises.
With ExitStack, you can even manage a dynamic set of resources determined at runtime — for example, opening an unknown number of log files during a rolling deployment.
Performance Considerations
Context managers add negligible overhead — typically a few microseconds per with statement due to the method lookup and call. In practice, the I/O cost of the underlying resource dwarfs the context manager overhead. Use them freely; the readability and safety benefits far outweigh any cost.
Summary
| Concept | Key Takeaway |
|---|---|
__enter__ / __exit__
|
The two methods that define a context manager |
with statement |
Auto-calls __enter__ and __exit__
|
@contextmanager |
Turn a generator into a context manager with less code |
closing() |
Wrap objects that have close() but no context manager support |
suppress() |
Silently ignore expected exceptions |
redirect_stdout |
Temporarily capture output streams for testing |
nullcontext |
A no-op placeholder for conditional usage |
ExitStack |
Manage dynamic or nested resources cleanly |
Context managers are one of Python's most practical abstractions. They eliminate entire categories of resource-leak bugs and make your code's intent clearer. The next time you open a file, acquire a lock, connect to a database, or change system state, reach for with — your future self will thank you.
Start using context managers in your Python projects today. Pick one try/finally block in your codebase and refactor it into a context manager. You will see the difference immediately.
Top comments (0)