Python Context Managers: The with Statement


Context managers are one of Python's most elegant features. it provde a clean and reliable way to manage resources like files, network connections, database connections, and locks.
The with
statement, introduced in Python 2.5, makes resource management safer and more readable by ensuring that cleanup code always runs, even when exceptions occur. This article will explore how to use existing context managers effectively and how to create your own custom ones for robust, production-ready code.
What Problem does Context Managers Solve?
Before understanding context managers, let's look at the problems they solve. Consider this common pattern when working with files:
file = open('data.txt', 'r')
data = file.read()
# Process data...
file.close() # This might not execute if an exception occurs above!
This code has a serious flaw: if an exception occurs between opening and closing the file, the file handle will never be closed, leading to resource leaks. The traditional solution uses try/finally blocks:
file = None
try:
file = open('data.txt', 'r')
data = file.read()
# Process data...
finally:
if file:
file.close()
While this works, it's verbose and error-prone. Context managers provide a much more cleaner solution:
with open('data.txt', 'r') as file:
data = file.read()
# Process data...
# File is automatically closed here, even if an exception occurs
Context managers are essential because they guarantee cleanup by always releasing resources even during exceptions, produce cleaner code by removing boilerplate try/finally blocks, prevent errors by reducing common resource management bugs, improve readability by making acquisition and release explicit, and enhance reliability through thread-safe resource handling.
How Context Managers Work: The Protocol
Context managers implement the context management protocol, which defines two special methods:
__enter__()
: Called when entering thewith
block, returning the resource to be managed.__exit__(exc_type, exc_value, traceback)
: Called when exiting thewith
block, regardless of whether the block exits normally or due to an exception.
Let’s see how this works with a simple example:
class SimpleContextManager:
def __enter__(self):
print("Entering the context")
return self
def __exit__(self, exc_type, exc_value, traceback):
print("Exiting the context")
print(f"Exception type: {exc_type}")
print(f"Exception value: {exc_value}")
# Return False to propagate any exception
return False
# Using our context manager
with SimpleContextManager() as manager:
print("Inside the context")
# Uncomment the next line to see exception handling in action
# raise ValueError("Something went wrong!")
# Output:
# Entering the context
# Inside the context
# Exiting the context
# Exception type: None
# Exception value: None
Understanding __exit__
Parameters
exc_type
→ The type of exception raised (orNone
if no exception occurred).exc_value
→ The actual exception instance (orNone
if no exception occurred).traceback
→ A traceback object that gives details about where the exception occurred (orNone
if no exception occurred).
If __exit__
returns True
, it suppresses the exception. If it returns False
or None
, the exception propagates normally.
Built-in Context Managers
Python provides many built-in context managers that you should use in your daily programming. Let's explore the most important ones:
File Handling
File objects are the most commonly used context managers:
# Reading files
with open('input.txt', 'r', encoding='utf-8') as file:
content = file.read()
lines = content.splitlines()
# Writing files
with open('output.txt', 'w', encoding='utf-8') as file:
file.write("Hello, Context Managers!\n")
file.writelines(["Line 1\n", "Line 2\n", "Line 3\n"])
# Working with multiple files simultaneously
with open('input.txt', 'r', encoding='utf-8') as infile, \
open('output.txt', 'w', encoding='utf-8') as outfile:
for line in infile:
processed_line = line.upper().strip() + "\n"
outfile.write(processed_line)
Threading Locks
Thread locks are perfect examples of resources that must be properly released:
import threading
import time
# Shared resource and lock
counter = 0
counter_lock = threading.Lock()
def increment_counter(name, iterations):
global counter
for _ in range(iterations):
# Without context manager (risky):
# counter_lock.acquire()
# counter += 1
# counter_lock.release() # Might not execute if exception occurs!
# With context manager (safe):
with counter_lock:
current = counter
time.sleep(0.0001) # Simulate processing time
counter = current + 1
print(f"{name}: {counter}")
# Create and start threads
threads = []
for i in range(3):
thread = threading.Thread(
target=increment_counter,
args=(f"Thread-{i+1}", 5)
)
threads.append(thread)
thread.start()
# Wait for all threads to complete
for thread in threads:
thread.join()
print(f"Final counter value: {counter}
Database Connections and Transactions
Database connections and transactions are critical resources that context managers handle beautifully:
import sqlite3
# Database connection as context manager
with sqlite3.connect('example.db') as conn:
cursor = conn.cursor()
# Create table
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE
)
''')
# Transaction is automatically managed
try:
cursor.execute("INSERT INTO users (name, email) VALUES (?, ?)",
("Alice Johnson", "alice@example.com"))
cursor.execute("INSERT INTO users (name, email) VALUES (?, ?)",
("Bob Smith", "bob@example.com"))
# Commit happens automatically if no exception occurs
except sqlite3.IntegrityError as e:
print(f"Database error: {e}")
# Rollback happens automatically on exception
# Example: Using a custom context manager for explicit transaction control
class DatabaseTransaction:
def __init__(self, connection):
self.connection = connection
def __enter__(self):
self.connection.execute("BEGIN")
return self.connection
def __exit__(self, exc_type, exc_value, traceback):
if exc_type is None:
self.connection.commit()
print("Transaction committed successfully")
else:
self.connection.rollback()
print(f"Transaction rolled back due to: {exc_value}")
return False # Don't suppress exceptions
# Usage
with sqlite3.connect('example.db') as conn:
with DatabaseTransaction(conn) as transaction:
cursor = transaction.cursor()
cursor.execute("INSERT INTO users (name, email) VALUES (?, ?)",
("Charlie Brown", "charlie@example.com"))
Creating Custom Context Managers: Class-Based Approach
Creating custom context managers gives you fine-grained control over resource management. you can encapsulate setup and teardown logic in a clean and reusable way.
Let’s walk through a practical example: a Timer context manager that measures how long a block of code takes to execute.
Timer Context Manager
import time
from typing import Optional
class Timer:
"""Context manager for timing code execution."""
def __init__(self, description: str = "Operation", precision: int = 4):
self.description = description
self.precision = precision
self.start_time: Optional[float] = None
self.end_time: Optional[float] = None
self.elapsed: Optional[float] = None
def __enter__(self):
print(f"Starting {self.description}...")
self.start_time = time.perf_counter()
return self
def __exit__(self, exc_type, exc_value, traceback):
self.end_time = time.perf_counter()
self.elapsed = self.end_time - self.start_time
if exc_type is None:
print(f"{self.description} completed in {self.elapsed:.{self.precision}f} seconds")
else:
print(f"{self.description} failed after {self.elapsed:.{self.precision}f} seconds")
return False # Don't suppress exceptions
Usage Examples
1. Timing a database query simulation:
with Timer("Database query"):
time.sleep(1.2) # Simulate database operation
2. Timing file processing with higher precision:
with Timer("File processing", precision=6):
numbers = [i**2 for i in range(100000)]
result = sum(numbers)
3. Accessing timing data directly:
with Timer("Data analysis") as timer:
time.sleep(0.5)
print(f"Analysis took {timer.elapsed:.2f} seconds")
The Timer
context manager makes it really easy to measure how long a piece of code takes to run. Instead of manually setting start and end times and wrapping everything in try/except, we just use a with
block. The timer starts automatically when the block begins and stops when it ends, printing out how long the operation took. Even if something goes wrong inside the block, the elapsed time is still reported. This keeps the code clean, reusable, and much easier to read.
Context Manager Best Practices
Always handle exceptions in
__exit__
: Decide whether to suppress or propagate.Keep
__enter__
and__exit__
balanced: Whatever is acquired in__enter__
should always be released in__exit__
.Don’t suppress exceptions unnecessarily: Return
False
from__exit__
by default so errors aren’t silently ignored.Handle cleanup errors gracefully: Log issues, but don’t hide them.
Testing Context Managers
When testing context managers, you need to cover both normal execution and failure scenarios to ensure resources are managed correctly in every case.
Here’s an example of how to test our Timer
context manager with unittest
:
import unittest
from unittest.mock import patch
from timer import Timer # assuming Timer is in timer.py
class TestTimerContextManager(unittest.TestCase):
def test_normal_execution(self):
"""Test timer with normal execution."""
with patch('time.perf_counter', side_effect=[1.0, 3.5]):
with Timer("test operation") as timer:
pass
self.assertAlmostEqual(timer.elapsed, 2.5)
def test_exception_handling(self):
"""Test timer with exceptions."""
with patch('time.perf_counter', side_effect=[1.0, 2.0]):
with self.assertRaises(ValueError):
with Timer("failing operation") as timer:
raise ValueError("Test exception")
self.assertAlmostEqual(timer.elapsed, 1.0)
def test_multiple_uses(self):
"""Test that timer can be reused."""
timer = Timer("reusable timer")
with patch('time.perf_counter', side_effect=[1.0, 2.0]):
with timer:
pass
self.assertAlmostEqual(timer.elapsed, 1.0)
with patch('time.perf_counter', side_effect=[3.0, 5.0]):
with timer:
pass
self.assertAlmostEqual(timer.elapsed, 2.0)
if __name__ == '__main__':
unittest.main()
Conclusion
Context managers are one of Python’s most powerful features for writing robust, maintainable, and predictable code. They provide a clean way to manage resources, guarantee proper cleanup, and make your code more explicit about when resources are acquired and released.
Drop a comment, and don’t forget to like and share the article.
Happy coding!
Subscribe to my newsletter
Read articles from Jimoh Yusuf directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Jimoh Yusuf
Jimoh Yusuf
Software Engineer with a passion for building secure, high-performance applications. I enjoy reading and exploring new ways to enhance software functionality and efficiency. I have strong hands-on experience developing softwares using Python, Go, Java, C++, Swift, Kotlin, and JavaScript. Having led teams, I value collaboration and teamwork and enjoy sharing my knowledge to help others grow