In the previous tutorial, we learned about decorators. Now let’s learn about context managers — the mechanism behind the with statement.
You have been using with since the file I/O tutorial:
with open("file.txt") as f:
content = f.read()
# File is automatically closed here
But what actually happens behind the scenes? And how do you create your own with-compatible objects? By the end of this tutorial, you will understand the protocol behind context managers and know how to build your own for resource management, timing, transactions, and more.
The Problem: Resource Leaks
Without with, you risk leaving resources open if an error occurs:
# BAD — file stays open if read() raises an error
f = open("file.txt")
content = f.read() # What if this line raises an error?
f.close() # This line never runs!
You could use try/finally:
# BETTER — but verbose
f = open("file.txt")
try:
content = f.read()
finally:
f.close()
The with statement does the same thing more cleanly:
# BEST — clean, safe, Pythonic
with open("file.txt") as f:
content = f.read()
# f.close() is called automatically, even on error
The with statement guarantees that cleanup code runs, no matter what happens inside the block. This pattern works for files, database connections, network sockets, locks, and any other resource that needs cleanup.
How Context Managers Work
A context manager is any object with two methods: __enter__ and __exit__.
When you write with something as name:, Python does this:
- Calls
something.__enter__()— the return value is assigned toname - Runs the
withblock - Calls
something.__exit__()— even if an error occurred
Class-Based Context Managers
Here is a Timer context manager that measures elapsed time:
import time
class Timer:
"""A context manager that measures elapsed time."""
def __init__(self, label: str = "Block") -> None:
self.label = label
self.elapsed = 0.0
self._start = 0.0
def __enter__(self):
"""Called when entering the 'with' block."""
self._start = time.perf_counter()
return self # This is what "as t" receives
def __exit__(self, exc_type, exc_val, exc_tb):
"""Called when exiting the 'with' block."""
self.elapsed = time.perf_counter() - self._start
return False # Do not suppress exceptions
Use it like this:
with Timer("my operation") as t:
time.sleep(0.1)
# do some work
print(f"Elapsed: {t.elapsed:.4f}s") # Elapsed: 0.1001s
Here is what happens step by step:
Timer("my operation")creates the Timer object__enter__is called, records the start time, returnsself(assigned tot)- The
withblock runs (sleep for 0.1 seconds) __exit__is called, calculates elapsed time- If an error occurred inside the block,
__exit__still runs
The exit Parameters
__exit__ receives three arguments about any exception that occurred:
exc_type— the exception class (e.g.,ValueError)exc_val— the exception instanceexc_tb— the traceback object
If no exception occurred, all three are None.
The return value of __exit__ matters:
- Return
False(orNone) — let exceptions propagate normally. This is what you want most of the time. - Return
True— suppress the exception. The error is swallowed and the code after thewithblock continues.
Suppressing Exceptions
You can create a context manager that catches specific exceptions:
class Suppressor:
"""A context manager that suppresses specific exceptions."""
def __init__(self, *exceptions):
self.exceptions = exceptions
self.suppressed = None
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None and issubclass(exc_type, self.exceptions):
self.suppressed = exc_val
return True # Suppress the exception
return False # Let other exceptions through
with Suppressor(ValueError, KeyError) as s:
int("not a number") # ValueError — suppressed!
print(s.suppressed) # ValueError: invalid literal for int()
# Code continues normally here
Python’s standard library has contextlib.suppress which does the same thing:
from contextlib import suppress
with suppress(FileNotFoundError):
Path("missing.txt").unlink()
# No error even if the file does not exist
File Writer Context Manager
Here is a practical example that wraps file writing with automatic closing:
class FileWriter:
"""A context manager for writing to a file."""
def __init__(self, path):
self.path = path
self.file = None
self.lines_written = 0
def __enter__(self):
self.file = open(self.path, "w", encoding="utf-8")
return self
def write(self, text: str) -> None:
"""Write a line to the file."""
self.file.write(text + "\n")
self.lines_written += 1
def __exit__(self, exc_type, exc_val, exc_tb):
if self.file:
self.file.close()
return False
with FileWriter(Path("output.txt")) as writer:
writer.write("Hello")
writer.write("World")
print(writer.lines_written) # 2
print(writer.file.closed) # True — guaranteed
Even if writer.write() raises an error, __exit__ closes the file.
Generator-Based Context Managers
The @contextmanager decorator from contextlib lets you create context managers using generators. This is often simpler than writing a class:
from contextlib import contextmanager
@contextmanager
def temp_directory():
"""Create a temporary directory and clean it up after use."""
import tempfile
tmp_dir = Path(tempfile.mkdtemp())
try:
yield tmp_dir # This is what "as" receives
finally:
# Cleanup: remove all files and the directory
for file in tmp_dir.iterdir():
file.unlink()
tmp_dir.rmdir()
with temp_directory() as tmp:
(tmp / "test.txt").write_text("Hello")
print(f"Working in: {tmp}")
# Directory is cleaned up here
The pattern for @contextmanager is always:
- Setup code — before
yield yield value— the value goes toas- Cleanup code — in
finally(or afteryieldif no errors are possible)
The try/finally ensures cleanup runs even if an error occurs inside the with block.
Logging Block
A simple generator-based context manager for logging:
@contextmanager
def log_block(name: str):
"""Log when entering and exiting a block."""
print(f"Entering {name}")
try:
yield name
finally:
print(f"Exiting {name}")
with log_block("database"):
print("Doing database work")
# Output:
# Entering database
# Doing database work
# Exiting database
Database Connection Pattern
Context managers are perfect for managing connections. The connection opens on enter and closes on exit:
class DatabaseConnection:
"""A simulated database connection context manager."""
_connections = []
def __init__(self, url: str) -> None:
self.url = url
self.connected = False
self.queries = []
def __enter__(self):
self.connected = True
DatabaseConnection._connections.append(self)
return self
def execute(self, query: str) -> str:
if not self.connected:
raise RuntimeError("Not connected to database.")
self.queries.append(query)
return f"Result of: {query}"
@classmethod
def active_count(cls) -> int:
"""Return number of active connections."""
return len(cls._connections)
def __exit__(self, exc_type, exc_val, exc_tb):
self.connected = False
if self in DatabaseConnection._connections:
DatabaseConnection._connections.remove(self)
return False
with DatabaseConnection("localhost:5432") as db:
result = db.execute("SELECT * FROM users")
print(result)
print(f"Active: {DatabaseConnection.active_count()}")
print(db.connected) # False — automatically disconnected
The connection is always closed, even if a query fails. This prevents connection leaks that can exhaust your database connection pool.
Nested Context Managers
You can nest with statements:
with temp_directory() as tmp:
with FileWriter(tmp / "output.txt") as writer:
writer.write("Hello from nested context")
Or use a single with with multiple context managers (Python 3.10+):
with (
temp_directory() as tmp,
FileWriter(tmp / "output.txt") as writer,
):
writer.write("Hello from single with")
The second syntax is cleaner and avoids deep nesting. All context managers are entered in order and exited in reverse order (last entered, first exited).
Transaction Pattern
Context managers can implement transaction-like behavior that rolls back changes on error:
@contextmanager
def transaction(data: dict):
"""Roll back changes to data if an error occurs."""
backup = dict(data) # Save a copy
try:
yield data
except Exception:
# Rollback: restore original data
data.clear()
data.update(backup)
raise # Re-raise the exception
data = {"name": "Alex", "score": 100}
try:
with transaction(data):
data["score"] = 200
data["new_key"] = "value"
raise ValueError("Something went wrong")
except ValueError:
pass
print(data) # {"name": "Alex", "score": 100} — rolled back!
The backup is made before the block runs. If an error occurs, the data is restored to the backup. The exception is re-raised so the caller knows something went wrong.
When to Use Context Managers
| Resource | Why use with |
|---|---|
| Files | Ensure files are closed |
| Database connections | Release connections back to pool |
| Network sockets | Close connections |
| Locks (threading) | Release locks to avoid deadlocks |
| Temporary files/dirs | Clean up temp files |
| Timer/profiling | Ensure timing stops |
| Transactions | Rollback on error |
| External processes | Ensure processes are terminated |
The rule is simple: if you need setup and cleanup that must always happen (even on error), use a context manager. It is the Pythonic way to manage resources.
Class vs Generator Context Manager
| Approach | Best for |
|---|---|
Class (__enter__/__exit__) | Complex state management, multiple methods |
@contextmanager | Simple setup/cleanup, minimal state |
Most context managers are simpler with @contextmanager. Use a class when you need to store state across multiple method calls or when the context manager is complex enough to benefit from proper class structure.
Common Mistakes
Forgetting to Return False
# BAD — accidentally suppresses all exceptions!
def __exit__(self, exc_type, exc_val, exc_tb):
self.cleanup()
return True # Suppresses ALL exceptions
# GOOD — let exceptions propagate
def __exit__(self, exc_type, exc_val, exc_tb):
self.cleanup()
return False
Not Using finally in Generator Context Managers
# BAD — cleanup does not run on error
@contextmanager
def risky():
resource = acquire()
yield resource
release(resource) # Not reached if error occurs!
# GOOD — cleanup always runs
@contextmanager
def safe():
resource = acquire()
try:
yield resource
finally:
release(resource) # Always runs
Yielding Multiple Times
A @contextmanager generator must yield exactly once:
# BAD — yields twice!
@contextmanager
def broken():
yield "first"
yield "second" # RuntimeError!
If you need to yield multiple values, yield a tuple or an object.
Context Managers in the Standard Library
Python has many built-in context managers:
# File operations
with open("file.txt") as f:
...
# Temporary files
with tempfile.TemporaryDirectory() as tmp:
...
# Suppress exceptions
with contextlib.suppress(FileNotFoundError):
...
# Change working directory (Python 3.11+)
with contextlib.chdir("/tmp"):
...
# Threading locks
import threading
lock = threading.Lock()
with lock:
# Only one thread can be here at a time
shared_data += 1
# Database transactions (SQLAlchemy example)
with Session() as session:
session.add(user)
session.commit()
The with statement is one of Python’s most powerful patterns. Once you understand it, you will see opportunities to use it everywhere.
Async Context Managers
If you work with async code (covered in a later tutorial), you can create async context managers:
class AsyncDB:
async def __aenter__(self):
self.conn = await connect()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.conn.close()
return False
# Used with "async with"
async with AsyncDB() as db:
await db.execute("SELECT 1")
Or with @asynccontextmanager:
from contextlib import asynccontextmanager
@asynccontextmanager
async def async_temp_file():
path = create_temp()
try:
yield path
finally:
await async_delete(path)
We will cover async in more detail in a future tutorial.
Testing Context Managers
Context managers should be tested like any other code. Test both the happy path and the error path:
def test_timer_measures_time():
with Timer("test") as t:
time.sleep(0.01)
assert t.elapsed > 0
def test_file_writer_closes_on_error():
with temp_directory() as tmp:
path = tmp / "test.txt"
try:
with FileWriter(path) as writer:
writer.write("data")
raise ValueError("error")
except ValueError:
pass
assert writer.file.closed # File closed despite error
def test_transaction_rollback():
data = {"score": 100}
try:
with transaction(data):
data["score"] = 200
raise ValueError("error")
except ValueError:
pass
assert data["score"] == 100 # Rolled back
Test that cleanup happens even when exceptions occur. This is the whole point of context managers.
Source Code
You can find the code for this tutorial on GitHub:
kemalcodes/python-tutorial — tutorial-15-context-managers
Run the examples:
python src/py15_context_managers.py
Run the tests:
python -m pytest tests/test_py15.py -v
What’s Next?
In the next tutorial, we will learn about type hints — how to annotate your code to catch bugs before they happen.
Related Articles
- Python Tutorial #14: Decorators — functions that modify functions
- Python Tutorial #12: File I/O — reading and writing files
- Python Cheat Sheet — quick reference for Python syntax