@contextmanager: Create a with Statement Context Manager Using a Simple Generator Function

Writing a context manager the traditional way requires a class with __enter__ and __exit__ methods. For simple setup/teardown patterns, that is more structure than the problem demands. The @contextmanager decorator from contextlib lets you write the same thing as a generator function with a single yield. Code before the yield is the setup. Code after the yield is the teardown. The yielded value is what the as clause receives. This article covers the full contract of that yield, how exceptions flow through it, how to use the result as both a context manager and a function decorator, and when the class approach is still the better choice.

The Class Approach vs the Generator Approach

A traditional class-based context manager requires defining a class with two dunder methods. Here is a context manager that temporarily changes the working directory and restores it on exit:

import os

class ChangeDirectory:
    def __init__(self, new_dir):
        self.new_dir = new_dir
        self.old_dir = None

    def __enter__(self):
        self.old_dir = os.getcwd()
        os.chdir(self.new_dir)
        return self.new_dir

    def __exit__(self, exc_type, exc_val, exc_tb):
        os.chdir(self.old_dir)
        return False  # do not suppress exceptions

with ChangeDirectory("/tmp") as path:
    print(os.getcwd())  # /tmp
print(os.getcwd())      # original directory

The same behavior expressed with @contextmanager:

from contextlib import contextmanager
import os

@contextmanager
def change_directory(new_dir):
    old_dir = os.getcwd()
    os.chdir(new_dir)
    try:
        yield new_dir
    finally:
        os.chdir(old_dir)

with change_directory("/tmp") as path:
    print(os.getcwd())  # /tmp
print(os.getcwd())      # original directory

The generator version is 8 lines instead of 14. The setup (os.chdir(new_dir)) appears before the yield. The teardown (os.chdir(old_dir)) appears after the yield in the finally block. The yielded value (new_dir) is what the as clause receives. No class, no self, no exc_type/exc_val/exc_tb parameters to manage.

The Yield Contract

The generator function decorated with @contextmanager must yield exactly once. The yield divides the function into two phases:

@contextmanager
def blueprint():
    # ---- SETUP PHASE (__enter__) ----
    # Acquire resources, configure state, open connections.
    # This runs when the 'with' block is entered.

    yield value  # 'value' is bound to the 'as' variable.
                 # The with block body executes here.

    # ---- TEARDOWN PHASE (__exit__) ----
    # Release resources, restore state, close connections.
    # This runs when the 'with' block exits.

If the generator yields zero times, @contextmanager raises RuntimeError("generator didn't yield"). If it yields more than once, it raises RuntimeError("generator didn't stop"). This is a strict contract: one yield, always.

What you yield determines what as receives. If you yield nothing (yield with no value), the as variable receives None. This is fine when the context manager manages state but does not produce a resource for the caller to use:

import sys
from contextlib import contextmanager
from io import StringIO

@contextmanager
def suppress_stdout():
    """Redirect stdout to /dev/null for the duration of the block."""
    old_stdout = sys.stdout
    sys.stdout = StringIO()
    try:
        yield  # nothing to give the caller
    finally:
        sys.stdout = old_stdout

with suppress_stdout():
    print("This will not appear")
print("This will appear")
Note

Under the hood, @contextmanager creates a _GeneratorContextManager object. Its __enter__ calls next() on the generator to advance it to the yield. Its __exit__ either calls next() again (if no exception) or generator.throw(exception) (if an exception occurred in the with block).

Exception Handling

When an exception occurs inside the with block, @contextmanager throws it into the generator at the yield point. This means you can catch, log, suppress, or re-raise exceptions using standard try/except syntax.

Guaranteed Cleanup With try/finally

Wrapping the yield in try/finally ensures teardown runs regardless of whether an exception occurs:

@contextmanager
def database_transaction(connection):
    cursor = connection.cursor()
    try:
        yield cursor
        connection.commit()
    except Exception:
        connection.rollback()
        raise
    finally:
        cursor.close()

If the with block completes without error, the code after yield calls commit(). If an exception occurs, the except block calls rollback() and re-raises the exception. Either way, the finally block closes the cursor.

Suppressing Exceptions

If the generator catches an exception and does not re-raise it, the exception is suppressed. This is the generator equivalent of returning True from __exit__:

import logging
from contextlib import contextmanager

logger = logging.getLogger(__name__)

@contextmanager
def log_and_suppress(*exception_types):
    """Log exceptions of the given types and suppress them."""
    try:
        yield
    except exception_types as e:
        logger.error("Suppressed %s: %s", type(e).__name__, e)
        # Not re-raising => exception is suppressed

with log_and_suppress(ValueError, TypeError):
    int("not a number")  # ValueError is logged and suppressed

print("Execution continues here")
Warning

If you write a bare yield without a try/finally and an exception occurs in the with block, the teardown code after yield will not execute. Always wrap yield in try/finally when cleanup must be guaranteed.

Practical Patterns

Timing a Code Block

import time
from contextlib import contextmanager

@contextmanager
def timer(label="block"):
    start = time.perf_counter()
    try:
        yield
    finally:
        elapsed = time.perf_counter() - start
        print(f"[{label}] {elapsed:.6f}s")

with timer("sorting"):
    data = sorted(range(1_000_000, 0, -1))
# [sorting] 0.072345s

Temporarily Overriding an Environment Variable

import os
from contextlib import contextmanager

@contextmanager
def env_var(name, value):
    """Temporarily set an environment variable, restoring on exit."""
    original = os.environ.get(name)
    os.environ[name] = value
    try:
        yield value
    finally:
        if original is None:
            del os.environ[name]
        else:
            os.environ[name] = original

with env_var("API_KEY", "test-key-123") as key:
    print(os.environ["API_KEY"])  # test-key-123

# API_KEY is restored to its original value (or removed)

Managed Database Connection

import sqlite3
from contextlib import contextmanager

@contextmanager
def db_connection(db_path):
    conn = sqlite3.connect(db_path)
    try:
        yield conn
    except Exception:
        conn.rollback()
        raise
    else:
        conn.commit()
    finally:
        conn.close()

with db_connection(":memory:") as conn:
    conn.execute("CREATE TABLE users (id INTEGER, name TEXT)")
    conn.execute("INSERT INTO users VALUES (1, 'Alice')")
    row = conn.execute("SELECT * FROM users").fetchone()
    print(row)  # (1, 'Alice')
# Connection is committed and closed automatically

Using @contextmanager as a Function Decorator

Since Python 3.2, context managers created with @contextmanager inherit from ContextDecorator, which means they can be applied directly as function decorators. When used this way, the entire function body runs inside the context manager's with block:

from contextlib import contextmanager
import time

@contextmanager
def timer(label="function"):
    start = time.perf_counter()
    try:
        yield
    finally:
        print(f"[{label}] {time.perf_counter() - start:.6f}s")

# Used as a context manager
with timer("inline"):
    sum(range(1_000_000))

# Used as a function decorator
@timer("decorated")
def compute():
    return sum(range(1_000_000))

compute()
# [inline] 0.012345s
# [decorated] 0.012678s
Pro Tip

When used as a decorator, the yielded value is not accessible because there is no as clause. This makes the decorator form ideal for context managers that manage state (like timing or logging) but do not need to hand a resource to the caller.

Async Context Managers

For async with statements, contextlib provides @asynccontextmanager, which works identically to @contextmanager but with an async generator:

from contextlib import asynccontextmanager

@asynccontextmanager
async def managed_session(url):
    session = await create_session(url)
    try:
        yield session
    finally:
        await session.close()

# Usage:
async def fetch_data():
    async with managed_session("https://api.example.com") as session:
        return await session.get("/data")

The async version was added in Python 3.7 and gained decorator support in Python 3.10.

When to Use a Class Instead

The @contextmanager decorator excels at simple setup/teardown pairs. A class-based context manager is the better choice when:

Scenario Use @contextmanager Use a Class
Simple setup/teardown pairYesOverkill
Need to return self from __enter__AwkwardNatural
Need state accessible after the with blockRequires a separate data objectStore on self
Complex __exit__ logic with exc_type checksLess clearExplicit parameters
Context manager is reusable across multiple with blocksNo (single-use)Yes (if designed for it)
Want dual use as decoratorBuilt-in since 3.2Inherit from ContextDecorator

Context managers created with @contextmanager are single-use. If you try to enter the same context manager a second time, the generator has already been exhausted and Python raises RuntimeError. Class-based context managers can be designed for reuse by resetting state in __enter__.

Key Takeaways

  1. @contextmanager converts a generator function into a context manager. Code before yield is the setup (__enter__). Code after yield is the teardown (__exit__). The yielded value becomes the as variable.
  2. The generator must yield exactly once. Zero yields produces RuntimeError("generator didn't yield"). More than one yield produces RuntimeError("generator didn't stop").
  3. Always wrap yield in try/finally for guaranteed cleanup. Without try/finally, an exception in the with block prevents the teardown code from executing. The finally block ensures cleanup runs regardless.
  4. Exception handling uses standard try/except syntax. If the generator catches an exception and does not re-raise it, the exception is suppressed. If it re-raises or does not catch it, the exception propagates normally.
  5. Since Python 3.2, the result works as both a context manager and a function decorator. When used as a @decorator, the entire function body runs inside the context. The yielded value is not accessible in decorator mode.
  6. Use a class when the context manager needs persistent state, reuse, or complex exit logic. @contextmanager produces single-use context managers that cannot be entered more than once. If you need self, reusability, or explicit exception-type inspection in __exit__, write a class.

@contextmanager is one of the standard library's most useful abstractions. It turns a pattern that requires a class with two dunder methods into a single function with a yield. For the majority of context management tasks -- temporary state changes, resource acquisition/release, timing, logging wrappers -- the generator approach is shorter, clearer, and sufficient.