Python asyncio Explained: async, await, gather and TaskGroup With Real Timings
asyncio lets one Python program wait on many slow things at once, such as network calls, without threads. We timed sequential versus concurrent code, broke things on purpose, and measured the one mistake that quietly removes every benefit.
- asyncio runs many waiting tasks on one thread, switching between them at each
await. - Three 0.5-second waits took 1.52 s one after another and 0.50 s with
asyncio.gather. - Prefer
asyncio.TaskGroup(Python 3.11+): when one task fails, the others are cancelled. - A blocking call such as
time.sleepinside a coroutine stalls everything. Use async libraries orasyncio.to_thread.
A script fetches data from three APIs, each taking half a second. Run one after another, that is 1.5 seconds of mostly waiting. With asyncio, the program starts all three requests, waits for them together, and finishes in about half a second. That is the whole idea: while one task waits, another can run.
We simulated slow I/O with asyncio.sleep and timed everything on Python 3.13. All timings and outputs below come from that run.
What is asyncio?
asyncio is Python’s built-in library for writing concurrent code with the async and await keywords. It uses an event loop: a scheduler that runs one piece of code at a time and, whenever that code reaches an await on something not ready yet, switches to another task that can make progress. This is called cooperative multitasking. It is similar in spirit to the JavaScript event loop.
It is concurrency, not parallelism. Only one piece of Python code runs at any instant; the gain comes from overlapping the waiting.
When asyncio helps, and when it does not
| Workload | Best tool |
|---|---|
| Many network calls, web servers, websockets, chat bots | asyncio |
| A few blocking I/O calls in existing code | Threads (concurrent.futures) |
| Heavy CPU work: maths, image processing, parsing | Processes (multiprocessing) |
| A simple script making one request | Plain synchronous code |
asyncio shines when there are many things to wait on, from dozens to thousands. It does nothing for code that keeps the CPU busy.
Coroutines and await
import asyncio
async def fetch(name, delay, fail=False):
await asyncio.sleep(delay) # stands in for a network call
if fail:
raise ConnectionError(f"{name} failed")
return f"{name} ok"
async def sequential():
return [await fetch("a", 0.5), await fetch("b", 0.5), await fetch("c", 0.5)]
asyncio.run(sequential())
# ['a ok', 'b ok', 'c ok'] in 1.52s
async def defines a coroutine function. Calling it does not run it; it creates a coroutine object. await runs a coroutine and waits for its result, letting the event loop do other work meanwhile. asyncio.run() starts the event loop and runs the top-level coroutine.
Notice the timing. Awaiting three things one after another is still sequential: 1.52 seconds. Writing async does not make code concurrent by itself.
Running things concurrently with gather
async def concurrent():
return await asyncio.gather(fetch("a", 0.5), fetch("b", 0.5), fetch("c", 0.5))
# ['a ok', 'b ok', 'c ok'] in 0.50s
asyncio.gather schedules all three at once and waits for all of them. The total time dropped to the longest single wait, 0.50 seconds, three times faster. Results come back in the same order as the arguments, not the order they finished.
Errors with gather
await asyncio.gather(fetch("a", 0.1), fetch("b", 0.2, fail=True))
# gather raised: b failed
await asyncio.gather(fetch("a", 0.1), fetch("b", 0.2, fail=True),
return_exceptions=True)
# ['a ok', ConnectionError('b failed')]
By default, the first exception propagates to the caller. A subtle detail: the other awaitables are not cancelled; they keep running in the background. With return_exceptions=True, errors come back as values in the result list, so you can handle each one. That suits jobs where partial success is acceptable, such as checking the status of many URLs.
TaskGroup: structured concurrency
Python 3.11 added asyncio.TaskGroup, which is now the recommended way to run tasks together:
try:
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(fetch("a", 0.1))
t2 = tg.create_task(fetch("b", 0.05, fail=True))
t3 = tg.create_task(fetch("c", 1.0))
except* ConnectionError as eg:
print([str(e) for e in eg.exceptions], t3.cancelled())
# ['b failed'] True
When task b failed, the group cancelled the still-running task c, waited for everything to settle, and raised an ExceptionGroup, caught here with the except* syntax. Nothing was left running in the background. This “all succeed or all stop” behaviour is why it is called structured concurrency, and it prevents a class of bugs where orphaned tasks keep working after their caller has given up. It uses the same async with pattern described in our context managers guide.
Timeouts
try:
async with asyncio.timeout(0.2):
await fetch("slow", 1.0)
except TimeoutError:
print("timeout after 0.2s")
await asyncio.wait_for(fetch("slow", 1.0), 0.2) # also raises TimeoutError
Every network call should have a timeout; a request that hangs forever can freeze a whole service. asyncio.timeout() (Python 3.11+) can wrap a block of several awaits, while wait_for wraps one. Both cancel the slow operation when time runs out.
The blocking-call mistake
This is the most damaging asyncio bug, because the code still works, just slowly:
async def bad():
time.sleep(0.5) # blocks the whole event loop
async def good():
await asyncio.to_thread(time.sleep, 0.5) # runs in a worker thread
await asyncio.gather(bad(), bad(), bad()) # 1.50s
await asyncio.gather(good(), good(), good()) # 0.53s
Three “concurrent” calls to bad() took 1.50 seconds, exactly as long as running them in sequence. time.sleep does not await, so the event loop could not switch tasks; everything froze until it finished. The same happens with requests.get(), synchronous database drivers, large file reads and heavy calculations inside a coroutine.
Tip: Run your app with asyncio.run(main(), debug=True) or PYTHONASYNCIODEBUG=1 while developing. Debug mode logs any task step that blocks the event loop for longer than a threshold, which points straight at hidden blocking calls.
Forgetting await
c = fetch("x", 0.01)
print(type(c).__name__) # coroutine
Without await, you get a coroutine object, not a result, and the code inside never runs. Python warns “coroutine was never awaited” when such an object is garbage-collected, but only at runtime. Type checkers such as mypy can catch many of these mistakes earlier, because the coroutine type does not match what the code expects.
Limiting concurrency
Starting 10,000 requests at once can overwhelm the server you are calling, trip its rate limits, or exhaust your own connections. A semaphore caps how many run at a time:
sem = asyncio.Semaphore(3)
async def job(i):
async with sem:
await asyncio.sleep(0.1)
await asyncio.gather(*(job(i) for i in range(10)))
# 10 jobs, limit 3: peak 3, took 0.42s
Never more than three jobs ran at once, and ten 0.1-second jobs finished in about 0.42 seconds, four rounds of up to three. Without the limit they would finish in 0.1 seconds, but a real API might start rejecting requests.
Async libraries
asyncio only helps if the code you call is async too. Common choices include httpx and aiohttp for HTTP, asyncpg and async SQLAlchemy for databases, and FastAPI or Starlette for web servers. For a library with no async version, wrap calls in asyncio.to_thread, as in our good() example. Async code tends to spread: once a function awaits, its callers must be async too, so plan where the async boundary sits in your application.
Testing async code
pytest needs a plugin to run coroutine tests; pytest-asyncio and AnyIO’s pytest plugin are the common choices, letting you write async def test_... functions. Use short fake delays, mock network calls, and include tests for timeouts, cancellation and one task failing among several, since those paths are where async bugs hide. Our pytest fixtures guide covers the setup side.
Should you go async? A quick checklist
- Is most of the time spent waiting? Measure first. If a request spends 90% of its time in network or database calls, async can help. If it spends that time computing, it will not.
- Do your libraries support it? An async web framework calling a synchronous database driver gains little and risks blocking the loop.
- How many things happen at once? Three API calls can be handled fine with a thread pool. Thousands of open connections are where asyncio clearly wins.
- Is the team ready? Async code changes how errors, cancellation and testing work. Start with one service or one script, not a full rewrite.
Many successful projects mix both: an async web layer for handling many connections, with CPU-heavy work sent to background workers or separate processes.
Final thoughts
asyncio overlaps waiting, not computing. Our three half-second waits dropped from 1.52 to 0.50 seconds with gather, but a single time.sleep in the wrong place erased the gain entirely. Use TaskGroup for related tasks, add timeouts to every external call, cap concurrency with a semaphore, and keep blocking work out of the event loop.
Frequently asked questions
What is asyncio in Python?
asyncio is the standard library for concurrent code using async and await. An event loop switches between tasks whenever one is waiting, so many I/O operations can overlap on a single thread.
Is asyncio faster than threads?
For large numbers of network-bound tasks it is usually more efficient, since tasks are lightweight. For a few blocking calls, threads are simpler. Neither helps CPU-bound work; use processes for that.
What is the difference between gather and TaskGroup?
Both run tasks concurrently. TaskGroup cancels the remaining tasks when one fails and raises an ExceptionGroup. gather by default raises the first error without cancelling the others.
Why is my async code not faster?
Usually because of blocking calls such as time.sleep or requests.get inside coroutines, or because awaits run one after another. Use async libraries, asyncio.to_thread, and gather or TaskGroup.
What happens if I forget await?
You get a coroutine object instead of a result, and its code never runs. Python warns that the coroutine was never awaited.
Keep learning
Need help with testing, WordPress or SEO?
We help teams ship better software and grow their sites. Tell us what you are working on and we will suggest a clear next step.