Skip to content
← All articles

contextmanager: the try/finally that is not optional

A bare yield runs cleanup on the happy path and silently not when the body raises — the exact case the context manager exists for. Plus: they are single-use, and the return type is Iterator[T].

@contextlib.contextmanager turns a generator into a context manager. Code before the yield is __enter__, code after it is __exit__, and the yielded value is what as binds.

@contextmanager
def temp_attr(obj: object, name: str, value: object) -> Iterator[None]:
    original = getattr(obj, name, _MISSING)
    setattr(obj, name, value)
    try:
        yield
    finally:
        if original is _MISSING:
            delattr(obj, name)
        else:
            setattr(obj, name, original)

The try/finally is the whole thing

Without it:

@contextmanager
def temp_attr(obj, name, value):
    original = getattr(obj, name, None)
    setattr(obj, name, value)
    yield
    setattr(obj, name, original)     # only reached on the happy path

When the body raises, contextlib throws that exception into the generator at the yield. With no try, the generator propagates it immediately and the restore line never executes. The attribute stays patched for the rest of the process.

This is the exact scenario a context manager exists to handle, so a version that only cleans up when nothing went wrong is worse than no context manager at all — it looks like it guarantees something it does not, and it passes every test that does not deliberately raise inside the block.

Use finally, not except. except requires you to re-raise and it is easy to swallow by accident; finally cleans up and lets the exception continue outward, which is almost always right.

💡A @contextmanager generator that swallows the exception — a click to reveal

bare except Exception: pass around the yield — suppresses it. What is the equivalent for a class-based context manager, and why is the generator version more dangerous? For a class, __exit__ suppresses by returning a truthy value. That is an explicit, greppable decision on one line, and the signature -> bool documents that suppression is possible.

In a generator, suppression happens by not re-raising — an absence rather than a presence. try: yield / except SomeError: pass looks like ordinary error handling and silently makes with swallow exceptions from the caller’s body. Reviewers read it as “handle this error”, not “the block’s exceptions now vanish”.

The related trap: a return inside the except has the same effect. If you genuinely mean to suppress, say so — contextlib.suppress exists, and its name is the documentation.

They are single-use

The object returned by calling a @contextmanager function wraps one generator instance. Entering it twice fails:

cm = temp_attr(obj, "mode", "test")
with cm: pass
with cm: pass       # raises

On CPython 3.12-3.14 that second __enter__ raises AttributeError, because __enter__ deletes the stored constructor arguments as it runs. The exact exception type is an implementation detail; the contract is “single-use”.

What is safe to reuse is the function: calling temp_attr(...) again builds a fresh generator. And using a @contextmanager function as a decorator (supported since 3.2) is safe for the same reason — it creates a new context manager per call, not per decoration.

3.15 changes ContextDecorator to detect generator and coroutine functions and keep the context manager open across iteration or await. That is a real behaviour change for a decorated generator function: today the manager closes when the generator is created, and on 3.15 it stays open until the generator is exhausted. If you decorate generators, test on both.

Annotate the return Iterator[T], not T

The most common mistake in the whole module:

@contextmanager
def opened(path: str) -> TextIO:        # WRONG
    ...
    yield handle

The generator function returns an iterator that yields TextIO. Its annotation is Iterator[TextIO] (or Generator[TextIO, None, None]); the decorator is what turns that into something whose __enter__ gives a TextIO. Annotating it -> TextIO is a straightforward lie and --strict catches it.

💡When should you write a class with __enter__/__exit__ instead click to reveal

of using @contextmanager? Four situations.

When it must be reusable or reentrant. The generator version is single-use by construction. A lock-like object you enter repeatedly, or nest inside itself, needs a class.

When it needs to inspect the exception. __exit__ receives (exc_type, exc_value, traceback) as ordinary arguments, which is far more readable than the generator equivalent (except-ing around the yield and re-raising).

When it is also a normal object. A database connection that supports with conn: and also has thirty methods is a class that happens to implement the protocol, not a context manager that happens to have methods.

When suppression is part of the contract. Returning True from __exit__ is explicit; not-re-raising in a generator is not.

For everything else — and that is most cases: acquire, yield, release — @contextmanager is shorter, and the try/finally makes the pairing visually obvious in a way that two separate methods do not.