Async Programming in Python: asyncio Explained
What asyncio actually does, shown with runnable timings: why three one-second waits finish in one second, how tasks, gather and TaskGroup differ, how to add timeouts and a concurrency limit, and the single blocking call that silently makes async code sequential again.
GloryTecks Team
Training & Placement Faculty
Last updated: 24 September 2026
Most Python programs spend their time waiting: for an API to answer, a database to return rows, a file to arrive. asyncio lets one thread wait on many of those things at once. It does not make your code faster at computing anything. It makes waiting overlap. This guide shows what that means with timings you can run, then covers the parts that trip people up: tasks, timeouts, limiting concurrency, and the blocking call that quietly undoes everything.
Three waits, one second
A coroutine is a function defined with async def. Calling it does not run it; it returns a coroutine object that runs when something awaits it. asyncio.run() starts the event loop and runs your top-level coroutine (Python docs: coroutines and tasks).
import asyncio
import time
async def fetch(name: str, delay: float) -> str:
await asyncio.sleep(delay) # stands in for a network call
return f"{name} done"
async def one_by_one():
start = time.perf_counter()
for name in ("a", "b", "c"):
await fetch(name, 1)
print(f"sequential: {time.perf_counter() - start:.1f}s")
async def together():
start = time.perf_counter()
results = await asyncio.gather(fetch("a", 1), fetch("b", 1), fetch("c", 1))
print(results, f"{time.perf_counter() - start:.1f}s")
asyncio.run(one_by_one())
asyncio.run(together())sequential: 3.0s
['a done', 'b done', 'c done'] 1.0sAwaiting one coroutine after another is still sequential. gather schedules all three, so their waits overlap. At each await, a coroutine hands control back to the event loop, which runs whatever else is ready. That is the whole trick: cooperative switching at await points, on one thread.
Coroutines, tasks and TaskGroup
A task is a coroutine the event loop has scheduled to run on its own. asyncio.create_task() starts it immediately in the background and returns a handle you can await later. gather wraps its arguments in tasks for you.
Since Python 3.11, asyncio.TaskGroup is the structured way to run several tasks. When the async with block ends, every task has finished. If one task fails, the group cancels the others and raises the errors together as an ExceptionGroup:
async def might_fail(n: int) -> int:
await asyncio.sleep(0.1 * n)
if n == 2:
raise ValueError(f"task {n} failed")
return n
async def main():
try:
async with asyncio.TaskGroup() as tg:
tasks = [tg.create_task(might_fail(n)) for n in (1, 2, 3)]
except* ValueError as eg:
print("errors:", [str(e) for e in eg.exceptions])
print("task 3 cancelled:", tasks[2].cancelled())
asyncio.run(main())errors: ['task 2 failed']
task 3 cancelled: TrueWith gather, by contrast, the first exception propagates to the awaiting code, but the other tasks keep running unless you cancel them. Prefer TaskGroup in new code on Python 3.11 or later. One more rule from the documentation: keep a reference to tasks you create with create_task, because the event loop keeps only a weak reference and an unreferenced task can disappear mid-run.
The blocking call that makes async sequential
The event loop can only switch at an await. Anything that blocks without awaiting, such as time.sleep(), requests.get() or a heavy calculation, holds the only thread, and every other coroutine waits behind it. The Python documentation warns against calling blocking code directly from a coroutine for exactly this reason (Python docs: developing with asyncio).
async def blocking_fetch(name: str) -> str:
time.sleep(1) # blocks the whole event loop
return f"{name} done"
async def offloaded_fetch(name: str) -> str:
await asyncio.to_thread(time.sleep, 1) # runs in a worker thread
return f"{name} done"
async def compare():
for fn in (blocking_fetch, offloaded_fetch):
start = time.perf_counter()
await asyncio.gather(*(fn(n) for n in "abc"))
print(f"{fn.__name__}: {time.perf_counter() - start:.1f}s")
asyncio.run(compare())blocking_fetch: 3.0s
offloaded_fetch: 1.0sNothing fails and no warning is printed; the program is simply as slow as the synchronous version. The fixes, in order of preference:
- Use a library built for asyncio: an async HTTP client instead of
requests, an async database driver instead of a synchronous one. - Wrap an unavoidable blocking call in
asyncio.to_thread()(Python 3.9+), as above. - Send CPU-heavy work to a process pool. asyncio does not help with computation at all.
Running with asyncio.run(main(), debug=True) makes the event loop log callbacks that take longer than 100 milliseconds, which is how you find the blocking call in a larger program.
Timeouts and a concurrency limit
Two controls matter as soon as the waits are real network calls. The first is a timeout, so one stuck request cannot hang the program. On Python 3.11+, asyncio.timeout() wraps a block; asyncio.wait_for() wraps a single awaitable on older versions:
async def slow():
await asyncio.sleep(5)
async def with_timeout():
try:
async with asyncio.timeout(0.5):
await slow()
except TimeoutError:
print("gave up after 0.5s")
asyncio.run(with_timeout())gave up after 0.5sThe second is a concurrency limit. gather over 10,000 URLs opens 10,000 requests at once, which can exhaust sockets or trip the server's rate limit. An asyncio.Semaphore lets only a fixed number of coroutines into a block at a time (Python docs: synchronization primitives):
async def limited_fetch(sem: asyncio.Semaphore, i: int, active: list) -> int:
async with sem: # at most 3 inside at once
active.append(i)
peak = len(active)
await asyncio.sleep(0.1)
active.remove(i)
return peak
async def crawl():
sem = asyncio.Semaphore(3)
active: list[int] = []
peaks = await asyncio.gather(*(limited_fetch(sem, i, active) for i in range(10)))
print("most at once:", max(peaks))
asyncio.run(crawl())most at once: 3When asyncio is the right tool
| Workload | Good fit? | Why |
|---|---|---|
| Calling many APIs or web pages | Yes | Hundreds of waits overlap on one thread |
| A web service handling many slow clients | Yes | Frameworks such as FastAPI run async def endpoints on an event loop |
| Chat servers, websockets, streaming | Yes | Many long-lived connections, mostly idle |
| Number crunching, image processing | No | CPU-bound: use processes (multiprocessing, concurrent.futures) |
| A script that makes three requests | Rarely | The complexity outweighs a saving of a second or two |
asyncio shines when the work is many independent I/O waits. The official documentation describes it as a good fit for I/O-bound and high-level structured network code (Python docs: asyncio). For how threads and processes compare, including the global interpreter lock, see multithreading vs multiprocessing in Python.
Common mistakes
- Calling a coroutine without awaiting it.
fetch("a", 1)on its own does nothing, and Python warns that the coroutine was never awaited. - Using asyncio.run() inside code that is already async, such as a Jupyter cell or a FastAPI endpoint. There, just
awaitthe coroutine. - Mixing in synchronous libraries without
to_thread: the blocking call above. - Unbounded fan-out. Always put a semaphore, or a fixed pool of worker tasks reading from an
asyncio.Queue, in front of large batches.
Is asyncio faster than threading?
Do I need Python 3.11 for this?
asyncio.run, gather and to_thread work on 3.9 and later. TaskGroup, except* and asyncio.timeout() need 3.11 or later.How do I call async code from ordinary synchronous code?
Can I use requests with asyncio?
asyncio.to_thread(), which gives up much of the benefit. For many requests, use an HTTP client with an async API.Next steps
asyncio sits in the concurrency module of the Python Programming course, alongside threading and multiprocessing. Async endpoints are also where API frameworks use it; the FastAPI guide shows one in practice.
Sources
- Coroutines and Tasks — Python documentation
- Developing with asyncio — Python documentation
- Synchronization Primitives — Python documentation
- asyncio — Asynchronous I/O — Python documentation
GloryTecks Team
Training & Placement Faculty
The GloryTecks faculty team mentors learners across Data Science, AI and Data Engineering, with a focus on real-time projects and placement readiness in Hyderabad.
Want to master Python?
Join GloryTecks’ expert-led Python Programming training in Hyderabad with real-time projects and 100% placement support.
Related articles
Object-Oriented Programming (OOP) in Python Explained
Object-oriented Python learned by building one small system, a shopping cart with discount rules: classes and instances, the shared-list bug, properties that protect invariants, special methods, abstract base classes, composition over inheritance, and dataclasses for plain data.
Learn Python from Scratch: A Complete 2026 Roadmap
How to learn Python from zero: install a current version (including the new Windows install manager), then work through seven stages, from values and loops to files, virtual environments, classes and tests, each ending with a small program you should be able to write on your own.
Python Interview Questions: Top 50 with Answers
Fifty conceptual Python interview questions with short, precise answers, grouped by topic: basics, types and mutability, data structures, functions and scope, OOP, iterators and decorators, errors and files, and internals, with runnable examples for the ones people most often get wrong.