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¶
- Python 3.11+, standard library only.
- Protocol basics from Async Context Managers & Iterators and the class-based approach in best practices for async context managers in Python.
- Cancellation rules from preventing CancelledError leaks in cleanup.
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.
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.
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 intry/finally, and no path yields twice or returns without yielding. - Exceptions are propagated: every
excepteither re-raises or suppresses one narrow, documented type — neverCancelledError. - Commit and rollback are distinct: success runs in
else, failure runs inexceptwith the exception re-raised. - Cleanup is bounded and ordered: releases have timeouts and run in reverse acquisition order.
- Managers are not reused: each
async withcreates a new manager, or the decorator form is used.
Pitfalls & edge cases¶
generator didn't yield. The generator returned or raised before reachingyield, often because a conditional skipped it. Validate inputs before thetry, and make theyieldunconditional.generator didn't stop. Execution reached a secondyieldafter the block exited — typically ayieldinside a loop or retry. Restructure so control flow after the firstyieldonly cleans up.- Awaiting inside
finallyduring cancellation. A second cancellation can interrupt the cleanup itself. Shield short, critical cleanup steps. - Returning values from the block. A
returninsideasync withstill 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 insidefinallyblocks 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.
Related¶
- Async Context Managers & Iterators — up to the topic overview for async resource protocols.
- Closing async generators with aclosing — the other generator lifecycle rule, for iteration rather than scoping.
- Asyncio Fundamentals & Event Loop Architecture — the section overview for coroutines and resources.