asyncio & async/await Interview Questions & Answers

6 questions Updated 2026-06-18

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.

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.

Practice tests are coming soon

Get notified when interactive mock interviews and quizzes launch.