Context Managers #

Every time you open a file, create a database connection, or acquire a lock, there’s a hidden responsibility: making sure that resource gets closed again — even if an error happens halfway through. Without proper handling, resource leaks are a problem that often stays invisible until the system starts behaving strangely. A context manager is Python’s mechanism for solving this elegantly: you define what happens when entering and leaving a context, and Python guarantees both always execute through the with statement.

The Problem Context Managers Solve #

Without context managers, resource-management code is full of boilerplate and prone to forgetting:

# ANTI-PATTERN: manual resource management — prone to resource leaks
file = open("data.txt", "r")
content = file.read()
file.close()  # what if file.read() raises an exception? close() won't be called!

# A manual "safe" version that's correct but verbose:
file = open("data.txt", "r")
try:
    content = file.read()
finally:
    file.close()  # finally ensures close() is always called

# CORRECT: use a context manager — concise and automatically safe
with open("data.txt", "r") as file:
    content = file.read()
# file.close() is called automatically here, including when an exception occurs

The with block guarantees that cleanup always runs, no matter what happens inside the block — including uncaught exceptions.


How It Works: The __enter__ and __exit__ Protocol #

An object can be used as a context manager if it implements two methods:

with MyContext() as obj:
    # do something

Execution flow:
    1. MyContext().__enter__() is called
       → its return value is bound to the variable `obj`
    2. The with block runs
    3. MyContext().__exit__(exc_type, exc_val, exc_tb) is called
       → always called, even when an exception occurs
       → if it returns True: the exception is swallowed (not propagated)
       → if it returns False/None: the exception propagates upward
The variable after as receives the return value of __enter__(), not the context manager instance itself. For open(), __enter__() returns the file object — that’s why you can write with open(...) as f and use f directly.

To visualize how the execution flow and protocol methods are called in sequence, look at the context manager lifecycle diagram below:

flowchart TD
    Start["with MyContextManager() as resource"] --> CallEnter["Call __enter__()"]
    CallEnter --> Setup["Setup/Allocate the Resource"]
    Setup --> ReturnVal["Return the Value to the 'resource' Variable"]
    ReturnVal --> RunBody["Run the Code Block inside 'with'"]
    RunBody --> CheckExc{"Did an Exception Occur?"}
    CheckExc -->|Yes| CallExitExc["Call __exit__(type, value, traceback)"]
    CheckExc -->|No| CallExitNormal["Call __exit__(None, None, None)"]
    CallExitExc --> CleanupExc["Close/Release the Resource"]
    CallExitNormal --> CleanupNormal["Close/Release the Resource"]
    CleanupExc --> HandleExc{"Does __exit__ return True?"}
    HandleExc -->|Yes| Suppress["Exception Suppressed (Normal Exit)"]
    HandleExc -->|No| Propagate["Propagate the Exception (Crash/Bubble Up)"]
    CleanupNormal --> End["Program Continues"]
    Suppress --> End

Creating a Context Manager with a Class #

The most explicit way is to define a class with __enter__ and __exit__ methods. Good for complex context managers or ones that need to store state.

import time

class Timer:
    """Context manager for measuring a code block's execution time."""

    def __enter__(self):
        self.start = time.perf_counter()
        return self  # bound to the variable after `as`

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.duration = time.perf_counter() - self.start
        print(f"Execution time: {self.duration:.4f} seconds")
        return False  # propagate the exception if any

with Timer() as t:
    total = sum(range(10_000_000))

print(f"Total: {total}, stored duration: {t.duration:.4f} seconds")

Another example — a context manager for a database connection:

import sqlite3

class DBConnection:
    def __init__(self, db_path):
        self.db_path = db_path
        self.connection = None

    def __enter__(self):
        self.connection = sqlite3.connect(self.db_path)
        return self.connection  # return the connection object, not self

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is None:
            self.connection.commit()   # success → commit
        else:
            self.connection.rollback() # error → rollback
        self.connection.close()
        return False  # propagate the exception

with DBConnection("app.db") as db:
    cursor = db.cursor()
    cursor.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER, name TEXT)")
    cursor.execute("INSERT INTO users VALUES (1, 'Budi')")
# automatic commit here — automatic rollback if there's an error

Handling Exceptions in __exit__ #

The exc_type, exc_val, exc_tb parameters give information about the exception that occurred. You can decide whether to swallow it or pass it through:

class SafeContext:
    def __enter__(self):
        print("Entering context.")
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is ValueError:
            print(f"ValueError caught and swallowed: {exc_val}")
            return True   # swallow the exception — code after `with` still runs
        if exc_type is not None:
            print(f"Other exception passed through: {exc_type.__name__}")
        return False  # propagate other exceptions

with SafeContext():
    raise ValueError("Invalid value")

print("This line still runs because the ValueError was swallowed.")
Be careful when swallowing exceptions with return True in __exit__. This can hide errors that the caller should have handled. Swallow an exception only if you genuinely know it can be safely ignored in that context.

Creating a Context Manager with @contextmanager #

For simpler context managers, the contextlib module provides the @contextmanager decorator, letting you write a context manager as a generator function — without defining a class.

from contextlib import contextmanager

@contextmanager
def timer(label="Operation"):
    import time
    start = time.perf_counter()
    try:
        yield  # the `with` block executes here
    finally:
        duration = time.perf_counter() - start
        print(f"{label}: {duration:.4f} seconds")

with timer("Compute total"):
    total = sum(range(10_000_000))

The try/yield/finally pattern is the standard idiom for @contextmanager:

from contextlib import contextmanager
import os

@contextmanager
def temp_directory(path):
    """Create a directory, run the block, then delete the directory."""
    os.makedirs(path, exist_ok=True)
    print(f"Directory '{path}' created.")
    try:
        yield path  # the yielded value is bound to the `as` variable
    finally:
        import shutil
        shutil.rmtree(path)
        print(f"Directory '{path}' removed.")

with temp_directory("/tmp/temp_work") as folder:
    # write a temporary file
    with open(f"{folder}/output.txt", "w") as f:
        f.write("temporary data")
    print(f"File created at: {folder}/output.txt")
# the directory is automatically removed after the block finishes

Class vs @contextmanager comparison:

Use a class when:
  ✓ The context manager needs to store complex state
  ✓ It needs to be inherited or extended
  ✓ There are many helper methods besides __enter__/__exit__

Use @contextmanager when:
  ✓ The logic is simple and linear
  ✓ You want more concise code
  ✓ No inheritance needed

Managing Multiple Context Managers #

Several with Statements in One Line #

Python allows opening several context managers at once in a single with statement:

# ANTI-PATTERN: unnecessary nested withs
with open("input.txt", "r") as input_file:
    with open("output.txt", "w") as output_file:
        output_file.write(input_file.read())

# CORRECT: combine into a single with
with open("input.txt", "r") as input_file, open("output.txt", "w") as output_file:
    output_file.write(input_file.read())

ExitStack for a Dynamic Number of Context Managers #

If the number of context managers isn’t known when the code is written — for example determined by configuration or user input — use contextlib.ExitStack:

from contextlib import ExitStack

def process_many_files(file_list):
    with ExitStack() as stack:
        # open all files dynamically
        file_handles = [
            stack.enter_context(open(f, "r"))
            for f in file_list
        ]
        # all files are open here
        for fh in file_handles:
            print(fh.readline())
    # all files are automatically closed after the block finishes

process_many_files(["a.txt", "b.txt", "c.txt"])

ExitStack can also be used to add cleanup callbacks dynamically:

from contextlib import ExitStack

with ExitStack() as stack:
    stack.callback(print, "Cleanup 1 executed")
    stack.callback(print, "Cleanup 2 executed")
    print("Doing work...")
# Output (reverse order — LIFO):
# Doing work...
# Cleanup 2 executed
# Cleanup 1 executed

Summary #

  • Context managers guarantee cleanup always runs — including when an exception occurs, through the always-called __exit__ mechanism.
  • The context manager protocol: __enter__() runs when entering the with block, __exit__() runs when leaving — the __enter__ return value is bound to the as variable.
  • return True in __exit__ swallows the exception — use it carefully; don’t hide errors that should be handled.
  • @contextmanager for simple cases — write a context manager as a generator with the try/yield/finally pattern without defining a class.
  • The value after yield in @contextmanager is bound to the as variable in the with block.
  • Combine several context managers in one with using commas to avoid unnecessary nesting.
  • ExitStack for a dynamic number of context managers — ideal when you don’t know how many resources need managing when the code is written.

← Previous: Multiprocessing   Next: Decorators →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact