Python asyncio in Practice: gather, Timeouts, Semaphores and TaskGroup
By Nitheesh Rajendran · · 4 min read
asyncio lets one Python thread juggle many tasks that spend most of their time waiting, such as network requests, database calls or sleeping. While one task waits, the event loop runs another. It does not make CPU-heavy code faster; for that, use processes or a library that releases the GIL.
The examples below were run on Python 3.15. asyncio.timeout and asyncio.TaskGroup need Python 3.11 or newer.
Coroutines and the event loop
An async def function returns a coroutine. It does nothing until it is awaited or scheduled.
import asyncio
async def fetch(name, delay):
await asyncio.sleep(delay) # stands in for a network call
return f"{name}:{delay}"
asyncio.run(fetch("a", 0.1)) # asyncio.run starts the event loop
Calling await fetch(...) three times in a row takes the sum of the delays. Running them concurrently takes about as long as the slowest.
Run tasks concurrently with gather
asyncio.gather runs awaitables together and returns their results in the order you passed them, not the order they finished.
async def main():
results = await asyncio.gather(
fetch("a", 0.3), fetch("b", 0.2), fetch("c", 0.1)
)
print(results)
# ['a:0.3', 'b:0.2', 'c:0.1'] total time: about 0.3 s, not 0.6 s
The three calls overlapped, so the total was close to the longest delay (0.3 s) instead of the 0.6 s sum.
Handling errors in gather
By default, if one awaitable raises, gather propagates the first exception, and the other tasks keep running in the background. To collect everything instead, pass return_exceptions=True:
async def bad():
raise ValueError("bad")
res = await asyncio.gather(fetch("ok", 0.01), bad(), return_exceptions=True)
print(res)
# ['ok:0.01', ValueError('bad')]
Exceptions now appear as values in the list, so check each item with isinstance(item, Exception).
Timeouts with asyncio.timeout
Never wait indefinitely on I/O. Python 3.11 added a context manager:
try:
async with asyncio.timeout(0.15):
await fetch("slow", 1)
except TimeoutError:
print("timed out")
# timed out
When the time limit passes, the awaiting task is cancelled and a TimeoutError is raised. For a single awaitable you can also write await asyncio.wait_for(coro, timeout=...). Always catch cancellation-related errors where you need to release resources, and re-raise asyncio.CancelledError rather than swallowing it.
Limit concurrency with a Semaphore
Starting 10,000 requests at once can overwhelm a server or exhaust file descriptors. A semaphore caps how many run at a time:
sem = asyncio.Semaphore(2)
active = peak = 0
async def job(i):
global active, peak
async with sem:
active += 1
peak = max(peak, active)
await asyncio.sleep(0.05)
active -= 1
await asyncio.gather(*(job(i) for i in range(6)))
print("peak concurrency:", peak)
# peak concurrency: 2
Six jobs were scheduled, but never more than two ran at once. For real HTTP work, set the limit to what the remote service tolerates.
Structured concurrency with TaskGroup
asyncio.TaskGroup (3.11+) is the modern way to run a set of tasks that belong together. Leaving the async with block waits for all of them, and if one fails, the others are cancelled.
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(fetch("x", 0.01))
t2 = tg.create_task(fetch("y", 0.02))
print(t1.result(), t2.result())
# x:0.01 y:0.02
Errors are collected into an ExceptionGroup, which you handle with except*. Compared with gather, a TaskGroup avoids "orphaned" tasks that keep running after a failure. Prefer it when you can require Python 3.11 or newer.
Mistakes to avoid
- Blocking the loop. A call such as
time.sleep(5)or a synchronous HTTP library stops every other task. Useawait asyncio.sleep(), an async client, orawait asyncio.to_thread(blocking_function). - Forgetting
await. Calling a coroutine function without awaiting it creates a coroutine that never runs; Python warns "coroutine was never awaited". - Fire-and-forget tasks being garbage collected. If you use
asyncio.create_task, keep a reference to the task (in a set or list), or it may be collected before it finishes. - Using asyncio for CPU work. Concurrency here means interleaving waiting, not parallel computation.
- Calling
asyncio.runinside a running loop, for example in a Jupyter notebook. There, useawaitdirectly.
When to use what
| Need | Tool |
|---|---|
| Run a known list of awaitables and collect results | asyncio.gather |
| A group of tasks that should succeed or fail together | asyncio.TaskGroup |
| Limit simultaneous operations | asyncio.Semaphore |
| Bound the waiting time | asyncio.timeout / wait_for |
| Run blocking code without freezing the loop | asyncio.to_thread |
Summary
gather overlaps waiting, timeout bounds it, a Semaphore limits it, and TaskGroup keeps related tasks together. Keep blocking calls out of the event loop, and you can handle thousands of I/O-bound operations from a single thread.