Python Context Managers: The with Statement

Jimoh YusufJimoh Yusuf
7 min read

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 the with block, returning the resource to be managed.

  • __exit__(exc_type, exc_value, traceback): Called when exiting the with 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 (or None if no exception occurred).

  • exc_value → The actual exception instance (or None if no exception occurred).

  • traceback → A traceback object that gives details about where the exception occurred (or None 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!

10
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