pyguides

Context Managers and the with Statement

Context managers solve a fundamental problem in Python: making sure your cleanup code runs, no matter what. Whether you’re opening a file, connecting to a database, or locking a thread, you need guarantees that resources get released. The with statement gives you exactly that.

The problem context managers solve

Consider opening a file without a context manager:

f = open("data.txt", "w")
f.write("hello")
# What if an exception happens here?
f.close()

If an exception occurs before close(), the file stays open and data may not flush to disk. Worse, open file handles accumulate and can exhaust the operating system’s file descriptor limit under load. The fix is to guarantee close happens regardless of what goes wrong. You could wrap it in try/finally:

f = open("data.txt", "w")
try:
    f.write("hello")
finally:
    f.close()

This works, but it’s verbose. Every resource acquisition needs its own try/finally block, and nesting them makes the code hard to follow. A safer pattern is the context manager protocol, which Python exposes through the with statement.

The with statement

The with statement automatically calls __enter__ at the start and __exit__ at the end, even if an exception occurs:

with open("data.txt", "w") as f:
    f.write("hello")
# File is automatically closed here

The as part captures whatever __enter__ returns. In this case, the file object itself.

The two-method protocol is straightforward: __enter__ sets up the resource and returns it, and __exit__ tears it down. Python calls __exit__ even if the code inside the block raises an exception, which is what makes the guarantee reliable. You can write your own context manager by implementing both methods on a class.

Building your own context manager

Create a class with __enter__ and __exit__ methods:

import time

class Timer:
    def __enter__(self):
        self.start = time.time()
        return self  # optional: return something for the 'as' clause
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        self.elapsed = time.time() - self.start
        print(f"Elapsed: {self.elapsed:.4f} seconds")
        return False  # don't suppress exceptions

with Timer() as t:
    sum(range(1000000))
# Elapsed: 0.0234 seconds

The __exit__ method receives exception details. Return True to suppress the exception, or False (default) to propagate it.

A timer is a simple example, but the real value of context managers shows up when the setup and teardown have side effects that must not be skipped. Database connections are the textbook case: open a connection at the start, commit on success, rollback on failure, and close in either case. Writing this logic by hand is error-prone, but a context manager bakes the correct sequence into the protocol.

Real-world example: database connection

import sqlite3

class DatabaseConnection:
    def __init__(self, db_path):
        self.db_path = db_path
        self.conn = None
    
    def __enter__(self):
        self.conn = sqlite3.connect(self.db_path)
        return self.conn
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        if self.conn:
            if exc_type is None:
                self.conn.commit()  # commit if no error
            else:
                self.conn.rollback()  # rollback on error
            self.conn.close()
        return False

# Usage
with DatabaseConnection("app.db") as conn:
    conn.execute("INSERT INTO users (name) VALUES (?)", ("Alice",))
# Connection closed, changes committed automatically

Using contextlib

Writing a class with __enter__ and __exit__ works everywhere, but it is a lot of boilerplate for simple cases. The contextlib module provides shortcuts so you do not need a full class. The two most useful are the @contextmanager decorator, which turns a generator function into a context manager, and closing, which wraps an object that has a close method.

contextmanager Decorator

from contextlib import contextmanager
import time

@contextmanager
def timer():
    start = time.time()
    try:
        yield  # This is where your code runs
    finally:
        elapsed = time.time() - start
        print(f"Elapsed: {elapsed:.4f} seconds")

with timer():
    sum(range(1000000))

The yield marks where the with block body executes. Code before yield runs in __enter__, code after in __exit__.

The decorator approach is ideal for one-off context managers where defining a class would feel heavy. The tradeoff is that the exception handling is less explicit: any exception raised inside the with block is re-raised at the yield point, so you must wrap yield in a try block if you need to react to errors. For objects that already have a close method and do not require custom logic, contextlib offers an even simpler tool.

closing()

Keep a context manager open when you just need cleanup:

from contextlib import closing
from urllib.request import urlopen

with closing(urlopen("https://example.com")) as page:
    content = page.read()
# Connection closed automatically

The closing wrapper calls the object’s close method when the block exits, regardless of whether the block raised an exception. It is the simplest way to turn a legacy object with a close method into a context manager. When you do not need cleanup at all but want to suppress a specific exception, suppress is the right tool.

suppress()

Ignore specific exceptions:

from contextlib import suppress
import os

with suppress(FileNotFoundError):
    os.remove("nonexistent.txt")
# No error if file doesn't exist

When to reach for context managers

Use context managers when you need to:

  • Guarantee cleanup happens (files, connections, locks)
  • Set up and tear down state
  • Temporarily modify global state and restore it
  • Measure execution time or resource usage

The with statement makes your code cleaner and safer. It moves cleanup out of your logic and into the protocol, where it’s harder to forget.

When to skip them

A context manager adds overhead. For simple cases where you don’t need guaranteed cleanup, a regular function call works fine. If you’re just transforming data and don’t need setup/teardown, skip it.

Stacking multiple managers

You can nest context managers or use multiple in a single with statement:

with open("input.txt") as infile, open("output.txt", "w") as outfile:
    outfile.write(infile.read())

This is equivalent to nesting but reads more cleanly. Both files close properly when the block exits.

Summary

Context managers give you a reliable protocol for resource cleanup. The with statement guarantees your teardown code runs even when exceptions occur, so you spend less time writing try/finally blocks and more time on your actual logic. Use the class-based approach when you need full control over setup and teardown, or reach for contextlib decorators and wrappers when a full class would be overkill. The protocol works with files, database connections, locks, and any resource that needs a guaranteed cleanup step.

See also