Skip to content

asyncio & async/await Interview Questions & Answers

16 questions Updated 2026-06-18 Share:

Python interview questions on asyncio and async/await: the event loop, coroutines vs threads, asyncio.gather, the don't-block-the-loop rule, and when async helps.

Read the in-depth guidePython asyncio Explained — Coroutines, the Event Loop, await, and Running Tasks Concurrently(opens in new tab)
16 of 16

asyncio is Python's framework for single-threaded concurrency using an event loop. The event loop is a scheduler that runs many coroutines cooperatively: when one coroutine awaits something (typically I/O), it yields control back to the loop, which runs another ready coroutine while the first waits. No thread is blocked sitting idle.

import asyncio

async def main():
    print("hello")
    await asyncio.sleep(1)    # yields to the loop instead of blocking
    print("world")

asyncio.run(main())           # creates the loop, runs main, then closes it

Crucially this is concurrency, not parallelism — one thread, one core, interleaving tasks at await points. Rule of thumb: asyncio shines when you have many tasks that spend most of their time waiting on I/O.

async def defines a coroutine function — calling it doesn't run the body, it returns a coroutine object that must be awaited or scheduled. await suspends the current coroutine until the awaited awaitable (another coroutine, a Task, or a Future) completes, handing control back to the event loop in the meantime.

import asyncio

async def fetch():
    await asyncio.sleep(1)    # suspension point — loop runs others here
    return "data"

async def main():
    coro = fetch()            # nothing has run yet
    result = await coro       # now it runs; main suspends until it finishes
    print(result)

asyncio.run(main())

You can only await inside an async def. Forgetting to await a coroutine is a common bug — it never runs and you get a "coroutine was never awaited" warning. Think of await as "pause me here and let others run until this is ready."

Threads use preemptive multitasking — the OS can switch threads at any point, so shared state needs locks and context switches are relatively expensive. Coroutines use cooperative multitasking on one thread — switches happen only at explicit await points, so the code between awaits is effectively atomic and switching is cheap.

import asyncio

async def task(name):
    print(f"{name} start")
    await asyncio.sleep(1)        # the ONLY place this can yield
    print(f"{name} done")

async def main():
    await asyncio.gather(task("a"), task("b"))   # thousands are feasible

asyncio.run(main())

Because there's no OS thread per task, you can run tens of thousands of coroutines cheaply, and most data races disappear. The catch: a coroutine that never awaits monopolizes the loop. Threads tolerate blocking code; coroutines do not.

Awaiting coroutines one by one runs them sequentially. To run them concurrently, schedule them together with asyncio.gather (or wrap each in a Task), which lets the loop interleave their await points.

import asyncio

async def fetch(n):
    await asyncio.sleep(1)
    return n * 2

async def main():
    # all three overlap -> ~1 second total, not 3
    results = await asyncio.gather(fetch(1), fetch(2), fetch(3))
    print(results)        # [2, 4, 6] — order matches the arguments

asyncio.run(main())

gather returns results in argument order and, by default, propagates the first exception. asyncio.create_task(coro) schedules a coroutine to start running immediately so it overlaps with later code. The key idea: concurrency comes from scheduling tasks together, not from awaiting them in turn.

Because asyncio runs on one thread, any code that doesn't await — heavy CPU work or blocking synchronous I/O like time.sleep, requests.get, or blocking DB drivers — freezes the entire loop. Every other coroutine stalls until that call returns.

import asyncio, time

async def bad():
    time.sleep(5)              # BLOCKS the whole loop for 5s

async def good():
    await asyncio.sleep(5)     # yields; other tasks keep running

# offload unavoidable blocking/CPU work to a thread or process pool:
async def offloaded():
    loop = asyncio.get_running_loop()
    await loop.run_in_executor(None, time.sleep, 5)   # runs in a thread

Fixes: use async-native libraries (aiohttp, asyncpg), and push CPU-bound or unavoidably-blocking calls into run_in_executor (a thread pool, or a process pool for CPU work). Rule of thumb: inside async code, never call something that blocks without awaiting it.

asyncio wins for high-concurrency I/O-bound workloads — thousands of network calls, web requests, websocket connections, or database queries that spend their time waiting. While one request waits, the loop services others, so a single thread handles huge concurrency cheaply.

import asyncio

async def call_api(i):
    await asyncio.sleep(0.5)        # stand-in for a network round trip
    return i

async def main():
    # 1000 "requests" overlap on one thread in ~0.5s of wall time
    results = await asyncio.gather(*(call_api(i) for i in range(1000)))
    print(len(results))             # 1000

asyncio.run(main())

It does not help CPU-bound work — that needs multiprocessing for real parallelism. And for a handful of blocking calls, plain threads are often simpler. Reach for asyncio when concurrency is high and the bottleneck is waiting on I/O.

Awaiting a coroutine directly runs it to completion before the next line — that is sequential. asyncio.create_task(coro) schedules the coroutine on the loop immediately and returns a Task you can await later, so work overlaps.

import asyncio

async def work(n):
    await asyncio.sleep(1)
    return n

async def main():
    await work(1); await work(2)          # sequential -> ~2s

    t1 = asyncio.create_task(work(1))      # both start now
    t2 = asyncio.create_task(work(2))
    await t1; await t2                     # concurrent -> ~1s

asyncio.run(main())

Rule of thumb: a bare await is "do this now and wait"; create_task is "start this in the background and collect it later".

gather runs awaitables concurrently and returns their results in order as a list; if one raises, gather propagates that exception (unless you pass return_exceptions=True). wait returns two sets — (done, pending) of Tasks — and never raises for you; you inspect each Task's result yourself.

results = await asyncio.gather(a(), b(), c())     # [ra, rb, rc], ordered

done, pending = await asyncio.wait(
    tasks, return_when=asyncio.FIRST_COMPLETED)   # set-based, manual

Rule of thumb: reach for gather when you want all results back in order; use wait only when you need fine control like "return when the first finishes".

By default the first exception propagates out of gather as soon as it occurs; the other tasks keep running but their results (and errors) are lost/ignored. With return_exceptions=True, exceptions are returned in the results list alongside normal values instead of being raised.

async def ok():  return 1
async def bad(): raise ValueError("boom")

res = await asyncio.gather(ok(), bad(), return_exceptions=True)
# res == [1, ValueError('boom')]  -> inspect each item

for r in res:
    if isinstance(r, Exception):
        print("failed:", r)

Rule of thumb: use return_exceptions=True when you want every task to finish and to handle failures individually rather than aborting on the first.

Run it in a thread so the event loop stays free. asyncio.to_thread(fn, *args) (3.9+) is the simple way; under the hood it uses loop.run_in_executor. The blocking call happens on a worker thread and the coroutine simply awaits the result.

import asyncio, time

def blocking_io():
    time.sleep(2)          # a real blocking call (DB driver, requests, ...)
    return "done"

async def main():
    result = await asyncio.to_thread(blocking_io)   # loop stays responsive
    print(result)

asyncio.run(main())

Rule of thumb: never call time.sleep, requests.get, or other blocking functions directly in a coroutine — wrap them in asyncio.to_thread (I/O) or use multiprocessing (CPU).

Wrap it with asyncio.wait_for(aw, timeout), which cancels the awaitable and raises asyncio.TimeoutError if it runs too long. Python 3.11 added the asyncio.timeout() context manager for guarding a whole block.

try:
    data = await asyncio.wait_for(fetch(), timeout=5)
except asyncio.TimeoutError:
    data = None                      # fell back after 5s

# 3.11+ context-manager form:
async with asyncio.timeout(5):
    data = await fetch()

Rule of thumb: any external call deserves a timeout — without one a single hung request can stall a coroutine forever.

Calling task.cancel() requests cancellation: at the task's next await, asyncio raises asyncio.CancelledError inside it. You can clean up in a try/finally or except CancelledError, but you should re-raise it — swallowing it breaks cancellation semantics.

async def worker():
    try:
        await asyncio.sleep(10)
    except asyncio.CancelledError:
        print("cleaning up")
        raise                       # re-raise so cancellation completes

async def main():
    t = asyncio.create_task(worker())
    await asyncio.sleep(0.1)
    t.cancel()
    await asyncio.gather(t, return_exceptions=True)

Rule of thumb: cancellation is cooperative — it only takes effect at an await point, and CancelledError should be re-raised after cleanup.

They are the async versions of with and for. async with drives an async context manager (__aenter__/__aexit__), e.g. acquiring a connection that itself awaits. async for iterates an async generator / async iterator (__anext__), awaiting between items — ideal for streaming I/O.

async def stream(url):
    async with session.get(url) as resp:     # awaits the connection
        async for line in resp.content:       # awaits each chunk
            process(line)

Rule of thumb: use them whenever the setup/teardown or the per-item step involves I/O that must be awaited.

Use an asyncio.Semaphore(n) to cap concurrency. Each coroutine acquires it before the protected work and releases on exit; only n can hold it at once, so the rest wait. This prevents hammering an API or exhausting connections when you fan out thousands of tasks.

sem = asyncio.Semaphore(10)          # at most 10 in flight

async def fetch(url):
    async with sem:                  # blocks if 10 already running
        return await get(url)

results = await asyncio.gather(*(fetch(u) for u in urls))

Rule of thumb: when you gather a huge number of tasks, gate them with a Semaphore so you control the real level of parallelism.

It is an async-aware queue for the producer/consumer pattern within one loop. await queue.put(item) and await queue.get() suspend instead of blocking the thread, letting producers and consumers run as concurrent tasks. task_done()/join() let you wait until all items are processed.

q = asyncio.Queue(maxsize=100)

async def producer():
    for i in range(10):
        await q.put(i)

async def consumer():
    while True:
        item = await q.get()
        ...                          # handle item
        q.task_done()

Rule of thumb: use asyncio.Queue (not queue.Queue) to hand work between coroutines — it cooperates with the event loop instead of blocking it.

asyncio.run() creates a new event loop and is meant to be the single top-level entry point. Calling it again while a loop is already running — common inside Jupyter notebooks or another async framework — raises RuntimeError. From inside a coroutine you should simply await, not call asyncio.run again.

async def main(): ...

asyncio.run(main())          # correct: top level, once

async def outer():
    asyncio.run(main())      # WRONG: loop already running
    await main()             # correct: just await

Rule of thumb: call asyncio.run once at the program's entry point; everywhere else inside async code, await the coroutine.

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel