โ† Back to playground

๐Ÿ”„Python asyncio

A staff interview guide to Python's asyncio โ€” the mental models and trade-offs an interviewer probes, for a strong Python engineer who hasn't written async before. Not an API tour: the event loop, the coroutine/task/future trio, structured concurrency, and the one rule that decides whether your service falls over.

TL;DR

  • asyncio is single-threaded cooperative concurrency for I/O-bound work. One thread runs an event loop that juggles thousands of tasks by letting each run until it awaits, then switching to another.
  • await is a suspension point, not a thread. It marks the only places a task can pause and yield control. Between two awaits your code runs uninterrupted โ€” which makes reasoning easy and makes one bad line catastrophic.
  • Know the trio: coroutine (the recipe) โ†’ Task (scheduled to run concurrently) โ†’ Future (a result, later). Awaiting a bare coroutine is sequential; wrapping it in a Task is what makes work overlap.
  • The cardinal rule: never block the loop. There is one thread, and no preemption. A CPU-bound loop or a synchronous call (time.sleep, requests.get) freezes every task at once. It is not parallelism โ€” under the GIL you get concurrency, not extra cores.

Why asyncio exists

Picture a web service that, per request, waits on a database, then an upstream API, then writes a response. It spends almost all of its wall-clock time waiting โ€” the CPU is idle, parked on a socket. The classic way to handle many such requests at once is one OS thread per connection. That works until it doesn't: each thread carries a stack measured in megabytes, and the kernel pays a context-switch cost every time it swaps them. At ten thousand mostly-idle connections โ€” the famous C10k problem โ€” the machine spends more effort scheduling threads than doing work.

asyncio makes a different bet: if the work is waiting, you don't need a thread for each wait. One thread runs an event loop. Each in-flight operation is a cheap coroutine โ€” a heap object, not an OS stack. When a coroutine would block on I/O, it registers interest with the loop's selector and yields; the loop moves on to whatever is ready now. Concurrency scales to tens of thousands of connections on a single core, because idle connections cost almost nothing.

Parallelism โ€” 2 cores, at the same instant core 1 task A running core 2 task B running Concurrency โ€” 1 thread, interleaved (asyncio) thread A B A B each switch happens where a task awaits โ†’ time
Concurrency is structure โ€” dealing with many things by interleaving them; parallelism is doing many things at the same instant. asyncio gives you the top-to-bottom win of the lower lane on a single thread.

So the scope is narrow and worth stating out loud in an interview: asyncio buys concurrency, not parallelism. It shines when you have many operations that spend their time waiting on the network or disk. It does nothing for a workload that keeps a CPU busy โ€” that needs multiple processes (see the comparison below).

Lineage

This is the same event-loop idea behind nginx, Node.js, and libuv, ported into Python's standard library (PEP 3156, 2012). It began on generator-based coroutines and got dedicated async/await syntax in Python 3.5 (PEP 492).

Mental model in 60 seconds

The event loop is a single thread running one job: keep a queue of ready-to-run tasks, take one, run it until it awaits something not yet ready, then set it aside and take the next. When an awaited operation completes โ€” a timer fires, a socket becomes readable โ€” its task goes back on the ready queue. Repeat until nothing is left to do.

event loop one thread ยท one task at a time 1 ยท pick a ready task from the ready queue 2 ยท run until it awaits no interruption in between 3 ยท park it waiting on I/O / a timer when the I/O completes, the task is put back on the ready queue โ†’
The whole model. A task only ever pauses at an await; while it waits, the loop runs someone else.

Three properties fall out of this, and they explain almost everything about how async code behaves:

  • Single-threaded. Your coroutine code never runs concurrently with itself. Within one uninterrupted run there are no data races on ordinary variables โ€” a relief compared to threads.
  • Cooperative, not preemptive. The loop can only switch tasks at an await. Between two awaits your code runs to completion, uninterrupted. That is the double edge of asyncio: you always know exactly where a switch can happen, but a task that never awaits (a busy CPU loop) never yields and starves the loop.
  • One loop, started with asyncio.run. It creates the loop, drives your top-level coroutine to completion, and closes the loop. One event loop per thread.
import asyncio

async def main():
    await asyncio.gather(worker("a"), worker("b"))

asyncio.run(main())   # create the loop, run main() to completion, close the loop
Use the front door

asyncio.run() (3.7+) is the entry point for new code. You will see older tutorials call get_event_loop() / run_until_complete() by hand โ€” avoid that; it is easy to get loop lifecycle wrong, and a common source of "there is no current event loop" errors.

Coroutines & await

An async def defines a coroutine function. The thing to internalize: calling it does not run it. It returns a coroutine object โ€” inert โ€” exactly the way calling a generator function returns a generator without executing the body. Nothing happens until you await it (or schedule it as a Task, or hand it to asyncio.run).

async def fetch(url): ...

fetch(url)         # returns a coroutine object โ€” the body never runs (RuntimeWarning)
await fetch(url)   # now it actually runs

await X does two things at once. It drives X toward its result, and โ€” the important part โ€” if X is not ready, it suspends the current coroutine and hands control back to the event loop. await is the only place a coroutine can pause, and you can only write it inside an async def. What you can await is any awaitable โ€” a coroutine, a Task, or a Future.

Task A Task B running await โ†’ yields running I/O ready resumes time
While Task A waits on I/O, the loop isn't idle โ€” it runs Task B. await is the handoff.

This is why await asyncio.sleep(1) and time.sleep(1) are worlds apart. asyncio.sleep suspends the coroutine for a second and lets the loop run everyone else; time.sleep blocks the whole thread โ€” and with it the loop and every other task. Hold that thought; it becomes the cardinal rule below.

The #1 beginner bug: a missing await

Forget the await and you create a coroutine that never runs โ€” silently, unless you notice the warning.

async def main():
    save(record)         # BUG: coroutine created, never awaited โ†’ the save never happens
    await save(record)   # fix

Python emits RuntimeWarning: coroutine '...' was never awaited. In an interview, "did you await that?" is a fast, credible catch.

Coroutine vs Task vs Future

Interviewers love this one because the three are easy to conflate and the difference is where concurrency actually comes from. Three related things:

  • Coroutine โ€” the recipe. What async def returns. Inert until driven. Awaiting a coroutine directly runs it inline, in the current task: sequential.
  • Task โ€” a coroutine the loop runs concurrently. asyncio.create_task(coro()) wraps the coroutine, schedules it on the loop, and returns immediately with a handle. The task advances whenever the current task awaits. You await the handle later to collect its result.
  • Future โ€” a result that will exist later. A low-level placeholder with a state (pending โ†’ done) and eventually a value or an exception. Libraries hand you Futures to bridge callback-style code; you rarely build one yourself. Crucially, Task is a subclass of Future โ€” a Future whose value is produced by running a coroutine.
Future a result, later ยท pending โ†’ done Task (is-a Future) create_task() โ†’ scheduled on the loop โ†’ runs concurrently Coroutine the async def body ยท await it directly โ†’ runs inline (sequential)
A Task is a Future that drives a coroutine. The nesting is the relationship.

The operational punchline โ€” worth being able to write on a whiteboard:

import asyncio

async def slow(n):
    await asyncio.sleep(n)
    return n

# Sequential โ€” await one, then the next. total โ‰ˆ 1 + 2 = 3s
a = await slow(1)
b = await slow(2)

# Concurrent โ€” schedule both, then await. total โ‰ˆ max(1, 2) = 2s
ta = asyncio.create_task(slow(1))   # both are running now
tb = asyncio.create_task(slow(2))
a, b = await ta, await tb        # they progressed while we awaited

Same coroutines, very different timing. Awaiting a coroutine is not the same as scheduling a Task. create_task is the verb that makes work overlap โ€” remember it and the rest of concurrency is ergonomics.

Running things concurrently

You rarely hand-roll create_task pairs. Two ergonomic tools cover almost everything.

asyncio.gather โ€” fan out, collect in order

Schedules every coroutine, waits for all of them, and returns their results positionally.

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

Watch the failure semantics โ€” a favorite follow-up. By default, if one coroutine raises, gather propagates that first exception to the caller but the others keep running (and if you never await them, they can leak). Pass return_exceptions=True and it instead returns exceptions as results rather than raising โ€” convenient, but it silently turns failures into return values, so you must inspect them.

asyncio.TaskGroup โ€” the modern default (3.11+)

A context manager that owns its child tasks. This is the recommended way to run concurrent work today.

async with asyncio.TaskGroup() as tg:
    t1 = tg.create_task(fetch(a))
    t2 = tg.create_task(fetch(b))
# the block exits only after both finish;
# if either raises, the other is cancelled and errors surface together
results = t1.result(), t2.result()

It is structured: control does not leave the async with block until every child is done, and if one fails, the siblings are cancelled and the errors are raised together as an ExceptionGroup. No orphaned tasks, no swallowed errors โ€” the two things gather makes easy to get wrong.

Fire-and-forget footgun

create_task hands the loop only a weak reference to the task. If you don't keep the handle yourself, the task can be garbage-collected mid-flight and vanish silently. Keep a reference (or just use a TaskGroup):

_background = set()

def spawn(coro):
    t = asyncio.create_task(coro)
    _background.add(t)                       # keep a strong ref...
    t.add_done_callback(_background.discard)   # ...drop it when finished
    return t

Structured concurrency & cancellation

Structured concurrency means a task's lifetime is bounded by a scope, so you can't leak tasks and errors can't disappear. TaskGroup is asyncio's implementation of the idea (borrowed from Trio's nurseries). Cancellation is the machinery that makes it work.

async with TaskGroup() as tg: t1 done โœ“ t2 raises โœ— t3 cancelled one child fails โ†’ siblings are cancelled โ†’ errors raised together as an ExceptionGroup
The group is all-or-nothing: it does not exit with a task still running, and it never loses an error.

Cancellation is cooperative too

task.cancel() does not kill anything on the spot. It schedules a CancelledError to be raised inside the coroutine at its next await. The coroutine may catch it to clean up โ€” but should re-raise, or cancellation silently fails to happen.

try:
    await do_work()
except asyncio.CancelledError:
    await rollback()   # clean up...
    raise              # ...then let the cancellation propagate

Put cleanup in finally. And note the type hierarchy is deliberate: CancelledError derives from BaseException, not Exception, precisely so a broad except Exception: doesn't accidentally eat a cancellation.

Timeouts

asyncio.timeout() (3.11+) is a context manager that cancels whatever runs inside it once the clock runs out โ€” built directly on cancellation.

async with asyncio.timeout(5):
    await slow_operation()          # raises TimeoutError if it runs > 5s

# older, still fine โ€” wraps a single coroutine:
await asyncio.wait_for(slow_operation(), timeout=5)

Both work by cancelling the inner operation, so the same rules apply: clean up in finally, don't swallow the CancelledError. When you have an operation that must not be interrupted by an outer cancel โ€” a commit, say โ€” wrap it in asyncio.shield(...), sparingly.

Never block the event loop

If you remember one operational fact about asyncio, make it this โ€” and expect an interviewer to push on it. There is one thread. Any code that runs without awaiting holds the loop hostage; nothing else โ€” not another request, not a health-check heartbeat โ€” makes progress until it returns. Two ways to commit this sin:

  1. CPU-bound work โ€” a tight loop, hashing, parsing a huge payload, resizing an image. It never awaits, so nothing else runs until it finishes.
  2. Synchronous I/O โ€” time.sleep, requests.get, a blocking database driver, a plain file read. These block the OS thread, and the loop is on that thread.

The symptom is distinctive: latency spikes across unrelated requests, all stalling in lockstep, because they share the one thread that is stuck.

await asyncio.sleep(1) โ€” yields A B C A B C all three tasks make progress time.sleep(1) / CPU loop โ€” blocks A holds the thread โ€” nothing else runs B C B and C are frozen until A returns
Same three tasks. Awaiting hands the loop around; one blocking call takes the whole loop down with it.

The fix is to get the blocking work off the loop thread.

# Blocking I/O call โ†’ run it on a worker thread, await the result
row = await asyncio.to_thread(blocking_db_query, sql)        # 3.9+

# CPU-bound work โ†’ a process pool, which sidesteps the GIL
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(process_pool, heavy_cpu, data)

asyncio.to_thread hands the call to a ThreadPoolExecutor and gives you back an awaitable; the loop keeps serving other tasks while the worker thread sits in the blocking call.

The GIL caveat โ€” say this precisely

Threads help for blocking I/O, because the GIL is released while a thread waits on I/O (and inside many C extensions). They do not give you parallel CPU โ€” the GIL still serializes Python bytecode, so CPU-bound threads just take turns. For CPU work you need separate processes (ProcessPoolExecutor or multiprocessing), which run independent interpreters. Rule of thumb: I/O that blocks โ†’ thread; CPU that grinds โ†’ process; a native async library exists โ†’ use it and stay on the loop.

Better still: don't block in the first place

Prefer async-native libraries so there's nothing to offload โ€” asyncpg for Postgres, httpx or aiohttp for HTTP, aiofiles for disk. to_thread is the escape hatch for libraries that only ship a synchronous API.

Coordinating tasks

Even on one thread you need coordination, for a subtle reason: state can change across an await. Between awaits your code is atomic, but at an await another task may run and mutate shared state. That is where async races live โ€” a check-then-act split by an await.

# NOT atomic: the await lets another task run between check and act
async def get(key):
    if key not in cache:              # check
        cache[key] = await load(key)   # two tasks can both miss and both load
    return cache[key]

Guard the critical section with an asyncio.Lock so the check-then-act runs to completion for one task at a time:

lock = asyncio.Lock()

async def get(key):
    async with lock:                 # serialize the whole check-then-act
        if key not in cache:
            cache[key] = await load(key)
        return cache[key]

Bounded concurrency with Semaphore

Fanning out ten thousand requests at once will exhaust sockets or file descriptors, or get you rate-limited. A semaphore caps how many run at a time while still letting you schedule them all.

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

async def fetch(url):
    async with sem:                  # waits here if 20 are already out
        return await client.get(url)

await asyncio.gather(*(fetch(u) for u in urls))   # 10k scheduled, 20 at a time
waiting t6 t5 t4 Semaphore(3) t1 t2 t3 done โ†’ frees a slot at most 3 run at once; each completion admits the next waiter
Bounded fan-out: schedule everything, but only N are ever inside the guarded block.

Producer / consumer with Queue

An asyncio.Queue decouples producers from consumers, and a maxsize gives you backpressure for free โ€” put suspends when the queue is full, throttling producers to the consumers' pace.

q = asyncio.Queue(maxsize=100)        # bounded โ†’ backpressure

async def producer():
    for item in source:
        await q.put(item)             # suspends when full

async def consumer():
    while True:
        item = await q.get()
        await handle(item)
        q.task_done()
These primitives are for coroutines, not threads

asyncio.Lock, Semaphore, Event, and Queue coordinate coroutines on one event loop โ€” they are not thread-safe like their threading namesakes. To poke the loop from another thread, use loop.call_soon_threadsafe(...) or asyncio.run_coroutine_threadsafe(coro, loop).

asyncio vs threads vs processes

The architecture question, and the one where a crisp answer signals seniority. Choose by what the work is waiting on.

Dimension asyncio threading multiprocessing
Best forI/O-bound, very high concurrencyI/O-bound with blocking librariesCPU-bound work
Unit of workCoroutine (~KB, on the heap)OS thread (~MB stack)Process (own interpreter)
SchedulingCooperative, 1 threadPreemptive, N threadsPreemptive, N processes
Parallel CPU?No โ€” one threadNo โ€” GIL serializes bytecodeYes โ€” separate interpreters
Switch costCheap (a function return)Kernel context switchHigh (IPC + pickling)
Sharing dataShared; safe within a stepShared; needs locksSerialize / IPC
Main hazardOne blocking call stalls allRaces & deadlocksIPC & memory overhead
Scales to10โดโ€“10โต tasks10ยฒโ€“10ยณ threads~ number of cores
asyncio 1 thread ยท event loop many coroutines, I/O-bound threading thread 1 thread 2 thread 3 shared memory ยท one GIL multiprocessing proc proc proc own interpreter each true CPU parallelism
Three shapes of concurrency. The GIL is why the middle box doesn't give CPU parallelism and the right box does.

The decision, in one breath: waiting on the network or disk with an async library available โ†’ asyncio. Waiting on a blocking library you can't replace โ†’ threads (or asyncio plus to_thread). Crunching numbers โ†’ processes. And they compose โ€” a common production shape is an asyncio server that offloads occasional CPU chunks to a ProcessPoolExecutor and stubborn blocking calls to a thread pool.

Worth a sentence in 2026

The free-threaded (no-GIL) build introduced experimentally in 3.13 could eventually change the threading calculus for CPU work. It's opt-in and maturing; the GIL-based reasoning above is still the safe default answer.

Interview gotchas

A tight checklist of the mistakes and "gotcha" questions that come up most. Being able to name and fix each is most of what "knows asyncio" means at this level.

  • Forgot the await. A coroutine created but never awaited never runs โ€” Python only whispers a RuntimeWarning.
  • Blocking the loop. time.sleep, requests, or a CPU loop freezes every task. Offload with to_thread / a process pool, or use an async-native library.
  • Sequential when you meant concurrent. await a(); await b() runs one after the other. Overlap them with gather, a TaskGroup, or explicit create_task.
  • Orphaned tasks. The result of create_task must be kept alive, or the task can be GC'd and silently dropped. Hold the handle or use a TaskGroup.
  • Swallowing CancelledError. It's a BaseException, so except Exception misses it โ€” but catching it explicitly and not re-raising breaks cancellation.
  • Trusting gather(return_exceptions=True). It converts failures into ordinary return values; if you don't inspect them, errors vanish.
  • Expecting parallelism. asyncio is one thread โ€” it will not speed up CPU-bound code. That's what processes are for.
  • Mixing sync and async. You can't await in a plain function, and calling an async def without awaiting (or a running loop) just makes an inert coroutine.
  • Racing across an await. Check-then-act split by an await isn't atomic; another task can slip in. Guard it with a Lock.
  • Cross-thread misuse. asyncio primitives aren't thread-safe; bridge threads and the loop with run_coroutine_threadsafe / call_soon_threadsafe.

Further reading