Skip to content

Building Async Context Managers with asynccontextmanager

@contextlib.asynccontextmanager turns a short async generator into an async with block, and it is how most teams write their transaction scopes, leases, timing spans and temporary resources. It is also easy to get subtly wrong. A try/except Exception: pass around the yield silently swallows the caller's errors. A broad except BaseException swallows cancellation, so a task that was told to stop finishes "normally" and a shutdown hangs. A manager stored in a variable and entered twice fails with a confusing error. And a commit placed after the yield without exception handling commits half-finished work. The rules that avoid all of this are few: exactly one yield, cleanup in finally, decide explicitly what an exception means, and let CancelledError through. This guide builds managers that follow them, including a transaction scope that commits or rolls back correctly, a lease with bounded release, and the decorator form.

Prerequisites

An async generator mapped onto async with 4 stacked layers from Before yield to finally / else. An async generator mapped onto async with Before yield __aenter__ acquire, begin yield value bound by as block runs here Exception at yield thrown into generator observe and re-raise finally / else __aexit__ release, commit One yield, and every path through the generator reaches finally.

1. Map the generator onto enter and exit

Everything before the yield is __aenter__, the yielded value is what as binds, and everything after is __aexit__. If the block raises, the exception is thrown into the generator at the yield. Wrap the yield in try/finally so cleanup runs on every path.

import asyncio
import contextlib
import time


@contextlib.asynccontextmanager
async def timed(name: str, sink: list[tuple[str, float, str]]):
    started = time.perf_counter()                  # __aenter__
    outcome = "ok"
    try:
        yield started                              # the value bound by `as`
    except BaseException as exc:
        outcome = type(exc).__name__               # observe, do not swallow
        raise
    finally:
        sink.append((name, round(time.perf_counter() - started, 3), outcome))   # __aexit__


async def main() -> None:
    spans: list[tuple[str, float, str]] = []
    async with timed("load", spans):
        await asyncio.sleep(0.02)
    try:
        async with timed("parse", spans):
            raise ValueError("bad payload")
    except ValueError:
        pass
    print(spans)                                   # [('load', 0.02, 'ok'), ('parse', 0.0, 'ValueError')]


asyncio.run(main())

Catching BaseException here is safe only because the handler re-raises immediately; it records the outcome, including CancelledError, without changing what happens. The finally block is the only place cleanup lives.

Verify: both spans are recorded, the second with outcome ValueError, and the ValueError still reaches the caller's except.

2. Decide what exceptions mean: a transaction scope

Resources with commit semantics need different exit paths for success and failure. Commit only when the block completed; roll back on any exception, including cancellation; and re-raise in every failure case.

import asyncio
import contextlib


class FakeConnection:
    def __init__(self) -> None:
        self.log: list[str] = []

    async def execute(self, sql: str) -> None:
        await asyncio.sleep(0)
        self.log.append(sql)


@contextlib.asynccontextmanager
async def transaction(conn: FakeConnection):
    await conn.execute("BEGIN")
    try:
        yield conn
    except BaseException:
        await asyncio.shield(conn.execute("ROLLBACK"))   # finish rollback even if cancelled
        raise
    else:
        await conn.execute("COMMIT")                     # only when the block completed


async def main() -> None:
    conn = FakeConnection()
    async with transaction(conn) as tx:
        await tx.execute("INSERT order 1")
    try:
        async with transaction(conn) as tx:
            await tx.execute("INSERT order 2")
            raise RuntimeError("payment declined")
    except RuntimeError:
        pass
    print(conn.log)


asyncio.run(main())

The else clause is the detail that matters: code placed directly after the yield would run only on success anyway, but writing it as else makes the intent unambiguous and keeps the commit out of the except path. Shielding the rollback prevents a second cancellation from interrupting it halfway — the reasoning in using asyncio.shield to protect critical sections. Real drivers such as asyncpg already provide conn.transaction(); the pattern is for your own resources with similar semantics.

Verify: the log reads BEGIN, INSERT order 1, COMMIT, BEGIN, INSERT order 2, ROLLBACK.

3. Never swallow cancellation or the caller's errors

The most damaging bug in generator-based managers is an except that does not re-raise. With except Exception, the caller's errors vanish. With except BaseException, cancellation vanishes too, and a cancelled task carries on as if nothing happened.

import asyncio
import contextlib


@contextlib.asynccontextmanager
async def swallows_everything():
    try:
        yield
    except BaseException:
        pass                                        # wrong: hides errors AND cancellation


async def victim() -> str:
    async with swallows_everything():
        await asyncio.sleep(10)                     # cancelled here...
    return "finished normally"                      # ...but execution continues


async def main() -> None:
    task = asyncio.create_task(victim())
    await asyncio.sleep(0.01)
    task.cancel()
    try:
        print("result:", await task)                # prints: result: finished normally
    except asyncio.CancelledError:
        print("cancelled as requested")


asyncio.run(main())

The task was cancelled and still returned a result. In a service that means shutdown waits for work it believes it stopped, or a timeout that "fired" did not stop anything. Suppression is occasionally intended — a manager that ignores one specific, expected exception — but it must be narrow, and it must never include CancelledError, KeyboardInterrupt or SystemExit.

Verify: the script prints result: finished normally; changing except BaseException: pass to except BaseException: raise makes it print cancelled as requested.

What should an except around yield do? A decision on An exception arrived at the yield with 3 outcomes. What should an except around yield do? An exception arrived at the yield default observe and re-raise log, record, raise resource with commit roll back and re-raise shield the rollback one expected type suppress narrowly never CancelledError A bare pass in the except turns a cancelled task into a finished one.

4. Bound slow cleanup and keep it in the right order

Cleanup is still awaited code on the loop. A release call that hangs turns every async with exit into a hang. Bound it, and when a manager acquires several things, release them in reverse order even if one release fails.

import asyncio
import contextlib
import logging

log = logging.getLogger("lease")


class LeaseClient:
    async def acquire(self, name: str) -> str:
        await asyncio.sleep(0)
        return f"lease:{name}"

    async def release(self, lease: str) -> None:
        await asyncio.sleep(10 if lease.endswith("slow") else 0)    # one release hangs


@contextlib.asynccontextmanager
async def leases(client: LeaseClient, *names: str, release_timeout: float = 0.1):
    acquired: list[str] = []
    try:
        for name in names:
            acquired.append(await client.acquire(name))
        yield acquired
    finally:
        for lease in reversed(acquired):                          # reverse order, every one
            try:
                async with asyncio.timeout(release_timeout):
                    await client.release(lease)
            except TimeoutError:
                log.warning("release of %s timed out; relying on server-side expiry", lease)


async def main() -> None:
    loop = asyncio.get_running_loop()
    started = loop.time()
    async with leases(LeaseClient(), "orders", "slow", "stock") as held:
        print("holding", held)
    print(f"exited after {loop.time() - started:.2f}s")          # about 0.10, not 10


logging.basicConfig(level=logging.WARNING)
asyncio.run(main())

Acquisition happens inside the try, so a failure on the third lease still releases the first two. Leases, locks and sessions that expire server-side make a timed-out release safe; resources without expiry need an alert instead. When the set of resources is dynamic or spans several managers, AsyncExitStack handles the ordering for you.

Verify: the block exits after about 100 ms despite the hanging release, with one warning logged for lease:slow.

5. Reuse safely: single-use objects and the decorator form

A generator-based manager object can be entered only once: the generator is exhausted after the first use, and entering the same object again fails. Create a fresh manager per async with. When the same scope should wrap a whole function, use the manager as a decorator — each call creates a new instance.

import asyncio
import contextlib


@contextlib.asynccontextmanager
async def span(name: str):
    loop = asyncio.get_running_loop()
    started = loop.time()
    try:
        yield
    finally:
        print(f"{name}: {loop.time() - started:.2f}s")


@span("sync_inventory")                           # decorator: a fresh manager per call
async def sync_inventory() -> None:
    await asyncio.sleep(0.01)


async def main() -> None:
    await sync_inventory()
    await sync_inventory()                        # fine: new generator each time

    manager = span("stored")
    async with manager:
        pass
    try:
        async with manager:                       # wrong: the same object entered twice
            pass
    except Exception as exc:
        print("re-entering a used manager failed:", type(exc).__name__)


asyncio.run(main())

Decorator use has existed since Python 3.10 via AsyncContextDecorator. The exact error from re-entering a used object is an implementation detail and has changed between versions; the rule does not change. If a manager genuinely must be reusable or re-entrant — a connection pool, a lock — write a class with __aenter__ and __aexit__ instead.

Reusing context managers A grid of 4 rows by 2 columns. Reusing context managers usage works twice? why stored generator manager no generator exhausted used as a decorator yes new manager per call call factory per block yes new generator each time class with __aenter__ if designed so state you control Generator-based managers are single-use objects; factories are not.

Verify: both decorated calls print a span, and entering the stored manager a second time raises.

Verification

Generator-based async context managers are correct when:

  • There is exactly one yield, wrapped in try/finally, and no path yields twice or returns without yielding.
  • Exceptions are propagated: every except either re-raises or suppresses one narrow, documented type — never CancelledError.
  • Commit and rollback are distinct: success runs in else, failure runs in except with the exception re-raised.
  • Cleanup is bounded and ordered: releases have timeouts and run in reverse acquisition order.
  • Managers are not reused: each async with creates a new manager, or the decorator form is used.

Pitfalls & edge cases

  • generator didn't yield. The generator returned or raised before reaching yield, often because a conditional skipped it. Validate inputs before the try, and make the yield unconditional.
  • generator didn't stop. Execution reached a second yield after the block exited — typically a yield inside a loop or retry. Restructure so control flow after the first yield only cleans up.
  • Awaiting inside finally during cancellation. A second cancellation can interrupt the cleanup itself. Shield short, critical cleanup steps.
  • Returning values from the block. A return inside async with still runs the manager's exit; the returned value is preserved unless the manager raises during cleanup.
  • Blocking cleanup. A synchronous close() on a file or socket inside finally blocks the loop; offload it or use the async variant.

Frequently Asked Questions

How does contextlib.asynccontextmanager work?

It wraps an async generator function. The code before the single yield runs as aenter, the yielded value is bound by async with ... as, and the code after the yield runs as aexit. If the block raises, the exception is thrown into the generator at the yield, where try, except and finally decide what happens.

Why does my asynccontextmanager swallow exceptions?

An except clause around the yield caught the exception and did not re-raise it, so the async with block appears to succeed. Re-raise in every except clause, or suppress only one specific expected exception type. Catching BaseException without re-raising also swallows CancelledError, so cancelled tasks keep running.

What does generator didn't yield or generator didn't stop mean?

generator didn't yield means the generator finished or raised before reaching its yield, so there was no value for the async with block. generator didn't stop means it reached a second yield after the block ended. A generator-based context manager must yield exactly once on every path.

Can I reuse an async context manager created with asynccontextmanager?

Not the same object. Each object wraps one generator, which is exhausted after one async with block, so entering it again fails. Call the decorated function again to get a fresh manager, use it as a decorator so each call creates one, or write a class-based manager if reuse is required.