Python Module 15 – Async programming with asyncio
Async iterators, generators and context managers
Consume async iterables with async for, write async generators and async context managers, and close them on time with aclosing and AsyncExitStack.
What you will learn
- Read async iterables with async for, aiter() and anext()
- Write async generators that fetch data page by page
- Write async context managers with asynccontextmanager and with classes
- Prevent leaks by closing async generators with contextlib.aclosing
- Manage a varying number of async resources with AsyncExitStack
Before you start
What you’ll learn · Before you start
What you will learn
- Read async iterables with async for, aiter() and anext()
- Write async generators that fetch data page by page
- Write async context managers with asynccontextmanager and with classes
- Prevent leaks by closing async generators with contextlib.aclosing
- Manage a varying number of async resources with AsyncExitStack
Before you start
On this page
An API that sends orders two to a page needs a network round trip for every page, and a plain for loop cannot wait
for one without blocking the event loop. async iterator: An object that async for can loop over: __aiter__() returns it, and each await of its __anext__() gives the next item or raises StopAsyncIteration at the end. Unlike a plain iterator, it may wait between items. can: they are what
async for loops over, objects that hand out their items one at a time and may await between them. The rows of a
database query and the pages of an API are typical. The easy way to write one is an
async generator: A function defined with async def whose body contains yield, and the async iterator that calling it returns. Its body runs only while the next item is awaited, so it can await a page of data and then yield the page's items one by one., an async def function that contains yield, so it can await the
next page and then yield its items.
async context manager: An object with the coroutine methods __aenter__() and __aexit__(). async with awaits the first before its block and the second after it, however the block ends; contextlib.asynccontextmanager makes one from an async generator. are the matching tool for async with: their
__aenter__() and __aexit__() are coroutines, so opening a connection or committing a transaction can wait without
blocking the loop, and contextlib.asynccontextmanager builds one from an async generator. Both protocols mirror the
synchronous for and with exactly, with one difference: every step may await. Each one is stepped through below on
a real run.
An async generator fetches a page only when the loop asks
Each fetch here waits 0.01 seconds in place of an HTTP request:
"""An async generator that pages through an API: each page is fetched only when the loop asks for more."""
import asyncio
# A fake API with three pages of orders; the fourth page is empty, which means "no more".
PAGES = {1: ["A-101", "A-102"], 2: ["A-103", "A-104"], 3: ["A-105"], 4: []}
async def fetch_page(number):
await asyncio.sleep(0.01) # stands in for an HTTP request
print(f" (fetched page {number})")
return PAGES[number]
async def orders():
"""Yield every order, one page at a time, until an empty page."""
page = 1
while True:
batch = await fetch_page(page)
if not batch:
return
for order in batch:
yield order
page += 1
async def main():
async for order in orders():
print("processing", order)
first_three = [order async for order in orders() if order != "A-102"][:3]
print("an async comprehension:", first_three)
asyncio.run(main()) Output
(fetched page 1) processing A-101 processing A-102 (fetched page 2) processing A-103 processing A-104 (fetched page 3) processing A-105 (fetched page 4) (fetched page 1) (fetched page 2) (fetched page 3) (fetched page 4) an async comprehension: ['A-101', 'A-103', 'A-104']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 paginated_api.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
orders() fetched page 1, yielded its two orders and paused. Only when the loop asked for a third order did it fetch
page 2, so the fetching and the processing took turns, and an empty page ended the generator with return. Step
through the first loop:
The drawing shows the whole scene; its 9 steps are listed below.
Every step as text
- The first request. async for calls __aiter__() once and awaits __anext__() for the first order. Only now does the body of orders() start to run.
- Page 1 is fetched. The body awaits fetch_page(1), a network round trip. While it waits, the event loop is free to run other tasks.
- Two orders arrive. Page 1 holds two orders, and the program prints (fetched page 1).
- yield A-101. The generator yields A-101 and pauses at its yield. The loop prints processing A-101.
- No fetch for A-102. The loop asks again. The generator resumes after its yield and hands out A-102 from the page it already holds, without a round trip.
- Page 2, only now. The third request finds page 1 used up, so only now does the body fetch page 2. Fetching and processing take turns.
- The same for pages 2 and 3. Page 2 gives A-103 and A-104, then page 3 gives A-105, each page fetched when the loop has used up the one before.
- An empty page. fetch_page(4) returns an empty list, so the body runs return and the generator is finished.
- The loop ends. A finished body makes __anext__() raise StopAsyncIteration, which async for catches. The loop ends quietly, after four fetches and five orders.
Async generators are lazy in the same way as the generators of an earlier lesson: nothing runs until something asks
for an item, and a page is held in memory only while its items are being handed out. Each yield is also where the
consumer’s own work runs, so a slow consumer slows the producer down: an async generator never runs ahead of the loop
that reads it.
The async comprehension at the end shows the opposite. [order async for order in orders() if ...] builds the whole
list, so it fetched all four pages before [:3] kept three orders. Comprehensions, async ones included, collect
everything; to stop early, use an async for loop with break.
Key idea
An async generator runs only while the loop awaits its next item, so it never runs ahead of the code that reads it.
Checkpoint
Check: An async generator fetches lazily
async for and async with call the awaited twins of for and with
As classes, Countdown and Timer show every method that async for and async with call:
"""What async for and async with call: __aiter__ and __anext__, __aenter__ and __aexit__."""
import asyncio
class Countdown:
"""An async iterator written as a class."""
def __init__(self, start):
self.n = start
def __aiter__(self):
return self
async def __anext__(self):
if self.n == 0:
raise StopAsyncIteration # the async version of StopIteration
await asyncio.sleep(0.01)
self.n -= 1
return self.n + 1
class Timer:
"""An async context manager written as a class."""
async def __aenter__(self):
print(" __aenter__: start")
return self
async def __aexit__(self, exc_type, exc, tb):
print(f" __aexit__: stop (exception: {exc_type.__name__ if exc_type else None})")
return False # do not suppress an exception
async def main():
async for n in Countdown(3):
print("async for got", n)
it = aiter(Countdown(2)) # the built-ins aiter() and anext() do what the statements do
print("anext:", await anext(it), await anext(it), await anext(it, "done"))
async with Timer():
print(" inside the block")
try:
async with Timer():
raise ValueError("bad row")
except ValueError as error:
print("the error went on:", error)
asyncio.run(main()) Output
async for got 3 async for got 2 async for got 1 anext: 2 1 done __aenter__: start inside the block __aexit__: stop (exception: None) __aenter__: start __aexit__: stop (exception: ValueError) the error went on: bad row
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 async_protocols.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Step through the same run. async for calls __aiter__() once and then awaits __anext__() for every item until it
raises StopAsyncIteration: The exception an async iterator's __anext__() raises when no items are left. async for catches it and ends quietly, as for does with StopIteration.; async with awaits __aenter__() before the block
and __aexit__() after it:
The drawing shows the whole scene; its 11 steps are listed below.
Every step as text
- __aiter__() once. async for calls __aiter__() once. Countdown returns itself, the object that hands out the items.
- Await the first item. Then it awaits __anext__() for the first item. The method sleeps 0.01 s, a wait that a plain __next__() could not make, and lowers n to 2.
- The item: 3. __anext__() returns 3, and the body of the loop prints async for got 3.
- Two more items. The next two awaits return 2 and 1, and n reaches 0.
- StopAsyncIteration. The fourth __anext__() finds n at 0 and raises StopAsyncIteration, the async version of StopIteration. async for catches it, and the loop ends.
- aiter() and anext(). The built-ins aiter() and anext() call the same two methods by hand. On Countdown(2) the program gets 2 and 1, and anext(it, "done") returns its default instead of raising: "anext: 2 1 done".
- async with enters. async with Timer() awaits __aenter__(), which prints "__aenter__: start". What it returns is what as would receive.
- The block runs. The body of the block runs and prints inside the block.
- async with exits. At the end of the block it awaits __aexit__() with three None values, because no exception left the block, and Timer prints "(exception: None)".
- An error in the block. The second block raises ValueError. async with awaits __aexit__() with the exception's type, value and traceback, and Timer prints "(exception: ValueError)".
- The error goes on. __aexit__() returns False, so the exception is not suppressed. It leaves the block, and the except around it prints "the error went on: bad row".
Each has a synchronous twin:
| Criterion | for and with | async for and async with |
|---|---|---|
| Iterator methods | __iter__() and __next__() | __aiter__() and async __anext__() |
| End of the items | StopIteration | StopAsyncIteration |
| Built-in functions | iter() and next() | aiter() and anext(), since Python 3.10 |
| Generator | def with yield | async def with yield |
| Context manager methods | __enter__() and __exit__() | async __aenter__() and __aexit__() |
| From a generator | contextlib.contextmanager | contextlib.asynccontextmanager |
| Where it may be used | anywhere | inside async def only |
| When to choose | Data that is already in memory | Data or resources you have to wait for |
Async iterators written as classes, like Countdown, are rare in application code, because an async generator does
the same in a few lines; the class form matters when an object needs other methods too. anext(it, default) returns
the default instead of raising at the end, as next() does. __aexit__() receives the exception that left the block,
or three None values, and a true return value would suppress the exception, exactly as in the synchronous
context managers of the errors module.
Swapping the two statements is a TypeError, and Python 3.14 names the right one:
"""Python 3.14 explains the mix-up between with and async with in the error message."""
import asyncio
import contextlib
@contextlib.asynccontextmanager
async def session():
yield "session"
@contextlib.contextmanager
def lock_file():
yield "lock"
async def main():
try:
with session():
pass
except TypeError as error:
print("with on an async context manager:\n ", error)
try:
async with lock_file():
pass
except TypeError as error:
print("async with on a plain context manager:\n ", error)
try:
async for _ in [1, 2]:
pass
except TypeError as error:
print("async for on a list:\n ", error)
asyncio.run(main()) Output
with on an async context manager: 'contextlib._AsyncGeneratorContextManager' object does not support the context manager protocol (missed __exit__ method) but it supports the asynchronous context manager protocol. Did you mean to use 'async with'? async with on a plain context manager: 'contextlib._GeneratorContextManager' object does not support the asynchronous context manager protocol (missed __aexit__ method) but it supports the context manager protocol. Did you mean to use 'with'? async for on a list: 'async for' requires an object with __aiter__ method, got list
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 with_vs_async_with.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Version note
The hints in these messages, “Did you mean to use ‘async with’?” and “Did you mean to use ‘with’?”, are new in Python 3.14. Python 3.13 says only that the object does not support the context manager protocol.
Checkpoint
Check: What async for and async with call
asynccontextmanager runs the code after yield on every way out
This generator lends a connection from a small pool:
"""asynccontextmanager turns an async generator into an async context manager: here, borrowing a connection from a
small pool, and always giving it back."""
import asyncio
import contextlib
free = ["conn-1", "conn-2"]
@contextlib.asynccontextmanager
async def borrowed():
await asyncio.sleep(0.01) # stands in for waiting for a free connection
conn = free.pop()
print(f" borrowed {conn}")
try:
yield conn # the body of the async with block runs here
finally:
free.append(conn)
print(f" returned {conn}")
async def main():
async with borrowed() as conn:
print(" query on", conn)
try:
async with borrowed() as conn:
raise ConnectionError(f"{conn} dropped the query")
except ConnectionError as error:
print(" caught:", error)
print("free connections:", sorted(free))
asyncio.run(main()) Output
borrowed conn-2 query on conn-2 returned conn-2 borrowed conn-2 returned conn-2 caught: conn-2 dropped the query free connections: ['conn-1', 'conn-2']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pool_context.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Everything before the yield is the __aenter__() part, and the value yielded is what as conn receives. The body of
the async with block runs at the yield, and the finally after it is the __aexit__() part, which ran both after
a normal block and after the ConnectionError. Because both parts are coroutines, waiting for an idle connection or
for a network round trip to close one does not block the event loop. Step through both blocks:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Before the yield. Entering the first block runs borrowed() up to its yield. It waits 0.01 s for a connection and takes conn-2 from the free list. This part plays __aenter__().
- yield conn-2. The value of the yield is what as conn receives. The generator pauses at its yield while the body of the block runs.
- The body runs. The body prints query on conn-2 and reaches the end of the block without an error.
- After the yield. The generator resumes after its yield, and its finally puts conn-2 back on the free list. This part plays __aexit__().
- Borrowed again. The second block borrows conn-2 again and gets it at the yield.
- The body raises. This time the body raises ConnectionError, conn-2 dropped the query. The generator is still paused at its yield.
- Thrown in at the yield. asynccontextmanager throws the exception into the generator at its yield. The exception runs the finally block on its way out, so conn-2 goes back before anything else happens.
- The error goes on. The generator does not catch the error, so it leaves the block, and the except around the block prints it. Both connections are free again.
A yield without try and finally
conn = free.pop(), yield conn, free.append(conn) loses the connection whenever the block raises: the exception
is raised inside the generator at the yield, so the line after it never runs. Put the clean-up in finally, as
borrowed() does, and it runs after a normal block, after an exception and after a cancellation, which arrives at
the yield as CancelledError.
Checkpoint
Check: asynccontextmanager and its finally
Three tools close generators and resources on time
A cursor, a socket or a pooled connection can be held too long in two ways. A loop that leaves with break stops
asking for items, but its generator stays paused at a yield instead of closed; and a stream that stops sending
leaves await anext() waiting with no end. Three tools from the standard library answer them:
| Tool | What it does |
|---|---|
contextlib.aclosing(gen) |
Closes an async generator when its async with block ends, even if the loop inside stopped early |
contextlib.AsyncExitStack |
Enters a number of async context managers known only at run time, and exits them all in reverse order |
asyncio.timeout() around each anext() |
Gives each item of a stream its own time limit |
Checkpoint
Check: Tools that close on time
Check yourself
One question at a time on paging, the two protocols, asynccontextmanager and the closing tools, the weakest idea first.
Practice that adapts to you
Practise this lesson
10 questions about 9 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Closing async generators on time: abandoned loops, stalled streams and aclosing Pro: opens with a Pro, Premium or Ultimate pass
An async generator that holds a resource, a cursor or a socket, keeps it until the generator is closed, and leaving a loop early does not close it. Below, that leak is reproduced, a stream that stops sending is caught, and the fixes are compared.
In this part:
- An abandoned generator, reproduced: the cursor closes late or only at shutdown
- A time limit per item: catching a stream that stops sending
- Choosing the fix: aclosing, a time limit per item or a queue
1 diagram · 2 runnable examples (Python) · about 8 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Async context managers for real resources: transactions, pools and many connections Pro: opens with a Pro, Premium or Ultimate pass
An async context manager is the natural owner of anything that must be given back: a transaction, a pooled connection, a set of connections to shards. Below, each one is built and tested under errors and cancellation, and the exercise builds a limiter of your own.
In this part:
- A transaction that rolls back on errors and on cancellation
- A connection pool as two classes, with its limit measured
- Many resources at once with AsyncExitStack
- Exercise: a lazy page reader and a limit you can use with async with
- A lazy page reader and a limit you can use with async with
- Results of the sample tests
1 diagram · 3 runnable examples (Python) · 1 exercise with sample tests · about 9 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Mid to senior
A streaming endpoint yields rows from an async generator that holds a database cursor. Clients often disconnect after a few rows, and under load the database runs out of connections. Why, and how do you fix it?
-
Mid to senior
Design an async context manager for a database transaction that commits on success, rolls back on any error and behaves correctly when the task is cancelled half-way. What must
__aexit__()do in each case? -
Mid to senior
An async generator pages through an API with 1,000 pages of 100 items, each page taking 300 ms to fetch, while the consumer needs 20 ms per item. What is held in memory, how long does the job take, and when would you put a queue between them?
Key takeaways
- An async generator, an
async defwithyield, awaits between items and fetches a page only when the loop asks. - An async comprehension builds the whole list, so a loop that stops early is an
async forloop withbreak. async forawaits__anext__()untilStopAsyncIteration,async withawaits__aenter__()and__aexit__(), and Python 3.14 says when the two are swapped.- With
asynccontextmanager, thefinallyafter theyieldgives the resource back on every way out of the block. aclosing(),AsyncExitStackand a time limit around eachanext()close generators and resources on time.
Terms in this lesson
- async context manager
- An object with the coroutine methods __aenter__() and __aexit__(). async with awaits the first before its block and the second after it, however the block ends; contextlib.asynccontextmanager makes one from an async generator.
- async generator
- A function defined with async def whose body contains yield, and the async iterator that calling it returns. Its body runs only while the next item is awaited, so it can await a page of data and then yield the page's items one by one.
- async iterator
- An object that async for can loop over: __aiter__() returns it, and each await of its __anext__() gives the next item or raises StopAsyncIteration at the end. Unlike a plain iterator, it may wait between items.
- StopAsyncIteration
- The exception an async iterator's __anext__() raises when no items are left. async for catches it and ends quietly, as for does with StopIteration.
References
- The Python Language Reference: the async for statement (Python Software Foundation)
- The Python Language Reference: the async with statement (Python Software Foundation)
- The Python Language Reference: asynchronous generator functions (Python Software Foundation)
- The Python Language Reference: asynchronous iterators and context managers (Python Software Foundation)
- Built-in Functions: aiter() and anext() (Python Software Foundation)
- contextlib: asynccontextmanager, aclosing and AsyncExitStack (Python Software Foundation)
- contextlib.aclosing() (Python Software Foundation)
- contextlib.AsyncExitStack (Python Software Foundation)
- Event Loop: loop.shutdown_asyncgens() (Python Software Foundation)
- What's New In Python 3.14: improved error messages (Python Software Foundation)
- PEP 525: Asynchronous Generators (Python Software Foundation)
- PEP 530: Asynchronous Comprehensions (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress