Asyncio Basics: async/await in Python

This closes out the concurrency mini-series covered in this course — threading, then multiprocessing, and now python asyncio. The single most important thing to understand before anything else: await does not mean parallel. This async await tutorial covers coroutines, the event loop, running things genuinely concurrently, and the common pitfalls that trip up nearly everyone the first time they use asyncio for real.

Introduction: Async Doesn't Mean Parallel

Killing the persistent myth upfront

await does not run code on another thread, and it does not run code in parallel. await tells the event loop, in effect: "I'm waiting on something external right now — go run something else in the meantime, and come back to me once this is ready." This is a genuinely fundamental distinction, and misunderstanding it is the single most common source of confusion for anyone new to asyncio.

Tying back to the series

Where multiprocessing (covered in the previous article) sidesteps the GIL entirely by running separate operating-system processes, and threading uses OS-level threads (still constrained by the GIL for actual Python bytecode execution), asyncio achieves I/O concurrency within a single thread, using something called cooperative multitasking — coroutines voluntarily yielding control back to a central event loop at await points, rather than the operating system forcibly interrupting them the way it does with OS threads.

What this article covers

Coroutines and the async def/await syntax, running multiple coroutines genuinely concurrently with gather() and the more modern TaskGroup, a working mental model of the event loop itself, and the specific pitfalls — blocking calls, forgotten task references — that catch nearly everyone at some point.

Coroutines, async def, and await

What a coroutine is

A coroutine is a function defined with async def that can pause at an await point and resume later, without blocking anything else in the meantime.

import asyncio

async def fetch_data():
    print("Starting fetch...")
    await asyncio.sleep(2)   # simulates waiting on a network call
    print("Fetch complete")
    return {"data": "example"}
Calling a coroutine doesn't run it

This is worth being precise about, since it surprises people: calling fetch_data() doesn't actually execute the function body at all — it returns a coroutine object, which must then be awaited (or otherwise scheduled) to actually run:

result = fetch_data()
print(result)   # <coroutine object fetch_data at 0x...> — nothing has run yet!

Nothing inside fetch_data() has executed at this point — not even the first print() statement. The coroutine object is essentially a paused, not-yet-started execution plan, waiting to be handed to something that will actually drive it forward.

asyncio.run(): the standard entry point
import asyncio

async def fetch_data():
    print("Starting fetch...")
    await asyncio.sleep(2)
    print("Fetch complete")
    return {"data": "example"}

async def main():
    result = await fetch_data()
    print(result)

asyncio.run(main())

asyncio.run() creates the event loop, runs the given top-level coroutine to completion, and cleanly closes the loop afterward — the standard, recommended way to kick off an asyncio program from ordinary, synchronous code.

An important caveat

asyncio.run() cannot be called from inside an already-running event loop — attempting to do so raises a RuntimeError. This specifically catches people working in environments like Jupyter notebooks, which already run their own event loop internally; in those environments, you generally need to await a coroutine directly at the top level instead, rather than wrapping it in asyncio.run().

Running Things Concurrently: gather() and TaskGroup

The critical distinction: sequential vs. concurrent

This is genuinely the most important practical lesson in this entire article: simply awaiting two coroutines back-to-back runs them sequentially, one after the other — not concurrently, despite the presence of await:

import asyncio
import time

async def fetch(name, delay):
    await asyncio.sleep(delay)
    return f"{name} done"

async def main():
    start = time.time()
    result1 = await fetch("A", 2)   # waits 2 seconds
    result2 = await fetch("B", 2)   # THEN waits another 2 seconds
    print(f"Total time: {time.time() - start:.2f} seconds")
    # Total time: 4.02 seconds — sequential, not concurrent!

asyncio.run(main())

Genuine concurrency requires explicitly scheduling coroutines together, using asyncio.gather() or asyncio.create_task()await alone doesn't provide it automatically.

asyncio.gather()
import asyncio
import time

async def fetch(name, delay):
    await asyncio.sleep(delay)
    return f"{name} done"

async def main():
    start = time.time()
    results = await asyncio.gather(
        fetch("A", 2),
        fetch("B", 2),
    )
    print(results)
    print(f"Total time: {time.time() - start:.2f} seconds")
    # Total time: 2.02 seconds — genuinely concurrent

asyncio.run(main())

gather() runs multiple awaitables concurrently, and collects their results in the same order they were passed in, regardless of which one actually finished first.

return_exceptiTrue

By default, if any single awaitable passed to gather() raises an exception, gather() immediately propagates it, potentially cancelling the other still-running tasks. Passing return_exceptiTrue changes this — exceptions are captured and returned alongside successful results, rather than immediately halting everything:

results = await asyncio.gather(
    fetch("A", 1),
    failing_fetch(),   # imagine this raises an exception
    return_exceptiTrue
)
# results contains the actual exception object in place of a normal result,
# rather than the whole gather() call failing immediately
asyncio.TaskGroup: the modern, recommended alternative

Since Python 3.11, asyncio.TaskGroup offers a more modern, structured-concurrency approach, with genuinely stronger safety guarantees than plain gather():

import asyncio

async def fetch(name, delay):
    await asyncio.sleep(delay)
    return f"{name} done"

async def main():
    async with asyncio.TaskGroup() as tg:
        task1 = tg.create_task(fetch("A", 2))
        task2 = tg.create_task(fetch("B", 2))

    print(task1.result(), task2.result())

asyncio.run(main())

The key advantage over gather(): if any task inside a TaskGroup raises an unhandled exception, the group automatically cancels every other still-running task in that same group, then re-raises — a much safer default than gather()'s behavior, which (without return_exceptiTrue) can leave other tasks running unmanaged in the background even after one has already failed.

A practical example: real wall-clock speedup
import asyncio
import time

async def fetch_url(url):
    print(f"Fetching {url}")
    await asyncio.sleep(1.5)   # simulating network latency
    return f"Content from {url}"

async def main():
    urls = ["url1", "url2", "url3", "url4"]

    start = time.time()
    results = await asyncio.gather(*(fetch_url(u) for u in urls))
    print(f"Concurrent: {time.time() - start:.2f} seconds")   # ~1.5 seconds

asyncio.run(main())

Four separate "fetches," each simulating 1.5 seconds of network latency, complete in roughly 1.5 seconds total when run concurrently — not 6 seconds, which is what running them sequentially would take. This is the genuine, real payoff asyncio offers for I/O-bound workloads with many concurrent operations.

The Event Loop and Common Pitfalls

A working mental model

Think of the event loop like an orchestra conductor: it advances each coroutine forward until that coroutine hits its next await, then moves on to whichever other task is ready to make progress next, cycling through all of them. Critically: it only ever runs one coroutine's code at any single instant — this is genuinely cooperative multitasking, not true parallel execution. Concurrency here comes entirely from efficiently interleaving waiting periods, not from literally running multiple pieces of Python code at the exact same moment.

The biggest pitfall: blocking the event loop

This is the single most damaging mistake to make with asyncio: running genuinely CPU-bound code inside a coroutine, with no await point, blocks the entire event loop — every other coroutine, no matter how many are scheduled, simply freezes until that blocking code finishes, since there's no yield point handing control back to the loop.

import asyncio
import time

async def bad_cpu_bound_task():
    # NO await anywhere in here — this blocks the ENTIRE event loop
    total = sum(i * i for i in range(50_000_000))
    return total

async def other_task():
    print("This won't run until bad_cpu_bound_task finishes!")

async def main():
    await asyncio.gather(bad_cpu_bound_task(), other_task())

asyncio.run(main())

asyncio only helps I/O-bound work — it provides zero benefit, and genuine harm, for CPU-bound code, exactly mirroring the GIL-related limitation covered for threading in the previous article, but for a different underlying reason (here, it's the total absence of any yield point, not the GIL itself).

The fix: run_in_executor()

For blocking, synchronous calls (a synchronous library with no async equivalent) or genuinely CPU-bound work, loop.run_in_executor() offloads the work to a thread pool (or a ProcessPoolExecutor, for CPU-bound work) without freezing the event loop itself:

import asyncio

def blocking_io_call():
    import time
    time.sleep(2)   # a synchronous, blocking call with no async equivalent
    return "done"

async def main():
    loop = asyncio.get_running_loop()
    result = await loop.run_in_executor(None, blocking_io_call)
    print(result)

asyncio.run(main())

run_in_executor(None, ...) runs blocking_io_call in a default thread pool executor, and — critically — awaits the result of that offloaded work without blocking the event loop itself in the meantime, letting other coroutines continue making progress while the blocking call runs in the background thread.

Other common mistakes worth flagging

Forgetting to keep a reference to a create_task() result. If you create a task and don't hold onto a reference to it, Python's garbage collector can end up cancelling it prematurely, sometimes silently:

# Risky — no reference kept, the task could be garbage-collected and cancelled
asyncio.create_task(fetch_data())

# Safer — hold a reference until the task completes
task = asyncio.create_task(fetch_data())
await task

Not handling exceptions inside gathered tasks. As covered above, gather() without return_exceptiTrue propagates the first exception it encounters, but any other tasks that were still running at that moment can be left in an ambiguous state — genuinely worth using TaskGroup instead for anything where you need reliable, predictable exception handling across multiple concurrent tasks.

Timeouts, Async Context Managers, and When to Choose Asyncio

asyncio.timeout(): bounding an operation's duration

Since Python 3.11, asyncio.timeout() is the modern, context-manager-based way to bound how long an operation is allowed to run:

import asyncio

async def main():
    try:
        async with asyncio.timeout(3):
            await fetch_data_that_might_hang()
    except TimeoutError:
        print("Operation timed out")

asyncio.run(main())

This is the recommended replacement for the older asyncio.wait_for() pattern — asyncio.timeout() reads more naturally as a with-block-scoped constraint, consistent with the with statement patterns covered in the earlier context managers articles in this series.

async with for asynchronous context managers

As covered in the earlier context managers article, some libraries define __aenter__/__aexit__ instead of the synchronous __enter__/__exit__ — genuinely necessary any time entering or exiting a context involves its own await-able operation, like acquiring a network connection:

async def main():
    async with some_async_http_client() as session:
        resp await session.get("https://example.com")

async with is required here specifically because some_async_http_client()'s setup and teardown themselves involve asynchronous operations (like establishing a connection) — a plain, synchronous with statement wouldn't know how to correctly await that setup and teardown.

Closing decision framework: tying the whole series together
  • asyncio — the right choice for a large number of I/O-bound operations happening concurrently — many simultaneous network calls, database queries, or similar — where the overhead of spinning up a separate OS thread per operation (as threading would require) would be genuinely wasteful. Asyncio's single-threaded, cooperative model scales to handle thousands of concurrent I/O-bound operations far more efficiently than an equivalent number of OS threads ever could.

  • threading — a good fit for simpler I/O-bound cases, or specifically when integrating with existing libraries that are synchronous and have no async equivalent available — asyncio requires an ecosystem of async-compatible libraries to really pay off, and not everything has one.

  • multiprocessing — the correct choice for genuinely CPU-bound work that needs real, actual parallelism across multiple CPU cores — something neither threading (limited by the GIL) nor asyncio (fundamentally single-threaded, and actively harmed by blocking CPU-bound code, as covered in Section 4) can meaningfully provide.

Across this entire concurrency mini-series, the recurring theme worth holding onto is this: match the concurrency tool to the actual shape of your workload — waiting-heavy versus computing-heavy, a handful of operations versus thousands — rather than reaching for whichever tool happens to be most familiar or most talked about.