Batu Lab NotesPractical developer guides

Why is a contextmanager generator required to yield exactly once?

By Batu ยท English technical notes

A generator used with @contextmanager must yield exactly once because that yield separates entering the with block from leaving it. With no yield, the context manager cannot supply an entered value. With a second yield, it cannot finish its exit protocol after the block. Python raises RuntimeError in each invalid case, but at different lifecycle points.

The first fixture is still a generator because its unreachable yield is syntactically present. When with no_value() tries to enter, it raises RuntimeError; the body never runs. two_values() yields once, so its body runs and records body, but leaving the with resumes the generator to a second yield and raises RuntimeError on exit. The valid fixture records an explicit enter/body/exit sequence.

The code catches exception types instead of comparing exception messages, which are implementation details that are less useful for a portable observation. Its assertions establish the three paths for these fixtures only. They do not validate resource handling; production cleanup belongs in a finally block around the single yield.

contextlib.contextmanager has been available since Python 2.5. Its documentation describes the decorator as turning a generator function into a context-manager factory and requires the generator to yield exactly one value.

AI assistance disclosure: this article and its synthetic example were prepared with AI assistance.

Example

from contextlib import contextmanager


@contextmanager
def no_value():
    if False:
        yield "never"


@contextmanager
def one_value(events):
    events.append("enter")
    try:
        yield "resource"
    finally:
        events.append("exit")


@contextmanager
def two_values(events):
    events.append("enter")
    yield "first"
    events.append("between")
    yield "second"


def exception_name(factory):
    try:
        with factory():
            pass
    except RuntimeError as error:
        return type(error).__name__


single_events = []
with one_value(single_events) as value:
    single_events.append("body")
    assert value == "resource"

double_events = []
zero_result = exception_name(no_value)
two_result = exception_name(lambda: two_values(double_events))
assert zero_result == "RuntimeError"
assert two_result == "RuntimeError"
assert single_events == ["enter", "body", "exit"]
assert double_events == ["enter", "between"]
print(f"zero yield: {zero_result} at entry")
print(f"one yield: {single_events}")
print(f"two yields: {two_result} at exit")

Expected output:

zero yield: RuntimeError at entry
one yield: ['enter', 'body', 'exit']
two yields: RuntimeError at exit