Quick Answer

async def creates a coroutine, and await hands control back to the event loop while something slow finishes. The loop runs one task at a time on one thread, so any blocking call (time.sleep, requests.get, a heavy loop) freezes every other task. Async helps when your program spends its time waiting on the network, a database or a disk. It does nothing for CPU-heavy work, which needs processes instead.

What await actually does

The single most common misunderstanding about asyncio is that it makes code run in parallel. It does not. A normal asyncio program runs on one thread. Nothing runs at the same time as anything else. What asyncio gives you is the ability to pause a function in the middle, go do something else, and come back later.

An async def function is not a normal function. Calling it does not run the body. It returns a coroutine object, which is a description of work that has not started yet. Something has to drive it, and that something is the event loop, usually started by asyncio.run().

import asyncio

async def fetch_marks(roll):
    print("asking for", roll)
    await asyncio.sleep(1)      # pretend this is a network call
    return {"roll": roll, "marks": 78}

# This does NOT run the function:
c = fetch_marks("CS21")
print(c)   # <coroutine object fetch_marks at 0x...>

# This does:
print(asyncio.run(fetch_marks("CS21")))

The word await is the interesting part. It means: this thing might take a while; if it is not ready, suspend me and let the loop run somebody else. Every await is a permission slip you hand to the scheduler. Code between two awaits runs start to finish with no interruption, which is why asyncio code rarely needs locks the way threaded code does.

The flip side is the rule that governs everything else in this article. If a stretch of code contains no await, the loop cannot take control back. Your function keeps the whole program to itself until it either finishes or hits an await. That is the entire model, and almost every asyncio bug is a violation of it.

If you forget the await entirely, Python does not crash. It builds the coroutine, throws it away, and prints RuntimeWarning: coroutine 'fetch_marks' was never awaited. Your function simply never ran, and the return value you were expecting is a coroutine object. Treat that warning as an error.

Blocking calls freeze the whole loop

Here is the failure mode that catches nearly everyone the first time. You write async code, add time.sleep(1) because it is the sleep you know, and the whole point of asyncio quietly disappears.

import asyncio, time

async def bad(n):
    time.sleep(1)          # blocks the thread, and so the loop
    return n

async def good(n):
    await asyncio.sleep(1) # yields control back to the loop
    return n

async def main():
    start = time.perf_counter()
    await asyncio.gather(*[bad(i) for i in range(5)])
    print("bad :", round(time.perf_counter() - start, 1), "s")

    start = time.perf_counter()
    await asyncio.gather(*[good(i) for i in range(5)])
    print("good:", round(time.perf_counter() - start, 1), "s")

asyncio.run(main())

The first block takes roughly five seconds, the second roughly one. time.sleep puts the operating system thread to sleep, and the event loop lives on that thread, so every other task is frozen with it. asyncio.sleep registers a timer and suspends only the current coroutine.

The same trap applies to real libraries, and it is much harder to spot there. requests.get() is blocking. So is open(...).read() on a slow disk, so is a psycopg or mysqlclient query, so is subprocess.run. Putting them inside async def changes nothing at all; they still hold the thread. Async only helps if the library itself is written to cooperate, which is why aiohttp and httpx.AsyncClient exist alongside requests.

When you are stuck with a blocking library, do not pretend. Push it onto a worker thread with asyncio.to_thread, available since Python 3.9, so the loop stays free.

import asyncio, requests

async def fetch(url):
    # requests is blocking, so run it off the loop thread
    return await asyncio.to_thread(requests.get, url)

Running things concurrently with gather and tasks

Awaiting one coroutine after another is still sequential. Three await statements in a row take as long as the three calls added together. To overlap them you have to hand the loop several jobs at once.

import asyncio

async def check_seat(city):
    await asyncio.sleep(1)
    return f"{city}: 12 seats"

async def main():
    # sequential, about 3 seconds
    a = await check_seat("Pune")
    b = await check_seat("Chennai")
    c = await check_seat("Jaipur")

    # concurrent, about 1 second
    results = await asyncio.gather(
        check_seat("Pune"),
        check_seat("Chennai"),
        check_seat("Jaipur"),
    )
    print(results)

asyncio.run(main())

asyncio.gather returns results in the order you passed them in, not the order they finished, which is usually what you want when you are zipping results back onto a list of inputs. By default, if one coroutine raises, gather re-raises immediately and the results of the others are lost. Pass return_exceptions=True to get a list where failures appear as exception objects instead, then filter them yourself.

asyncio.create_task is the other tool. It schedules a coroutine right away and gives you a handle you can await later, which is useful when you want work to start before you need the answer. There is a sharp edge here: the loop keeps only a weak reference to a task, so a task nobody holds a reference to can be garbage collected mid-flight and simply vanish. If you fire and forget, keep the handle in a set.

background = set()

def spawn(coro):
    task = asyncio.create_task(coro)
    background.add(task)
    task.add_done_callback(background.discard)
    return task

One more practical point: gather over ten thousand URLs will genuinely open ten thousand connections and get you rate limited or blocked. Put a asyncio.Semaphore(20) around the work and async with it inside each task to cap how many run at once.

I/O bound helps, CPU bound does not

Ask one question before reaching for asyncio: is my program slow because it is waiting, or because it is calculating? Async only helps with waiting.

Waiting means network requests, database round trips, reading files, calling a payment gateway, talking to Redis. During that wait the CPU is idle, and asyncio spends that idle time on another task. This is why an API server handling hundreds of slow database calls benefits enormously from async, even on a single core.

Calculating means resizing images, parsing a huge CSV, hashing passwords, running a sorting or dynamic programming routine, training anything. There is no waiting to reclaim. The CPU is already busy, and because CPython holds the global interpreter lock while running Python bytecode, threads will not save you either.

import asyncio
from concurrent.futures import ProcessPoolExecutor

def heavy(n):
    return sum(i * i for i in range(n))

async def wrong(n):
    return heavy(n)          # blocks the loop for the full duration

async def right(pool, n):
    loop = asyncio.get_running_loop()
    # a separate process has its own interpreter, so its own GIL
    return await loop.run_in_executor(pool, heavy, n)

async def main():
    with ProcessPoolExecutor() as pool:
        jobs = [right(pool, 5_000_000) for _ in range(4)]
        print(await asyncio.gather(*jobs))

if __name__ == "__main__":     # not optional on Windows or macOS
    asyncio.run(main())

Separate processes mean separate interpreters and separate GILs, so the work genuinely runs on several cores. The __main__ guard is required wherever child processes are started by spawning rather than forking, which includes Windows and modern macOS, because each child re-imports your module. The cost is that arguments and return values must be picklable and get copied between processes, so it only pays off when each job is chunky.

A quick decision rule for placement interviews and for real work: multiple slow network calls, use asyncio. A blocking library you cannot replace, use threads. Heavy computation, use processes. Mixing these up is the reason so many people report that async made no difference at all.

Mistakes that cost hours

Calling asyncio.run inside a running loop. In a Jupyter notebook, or inside an already-async web handler, asyncio.run() raises RuntimeError: asyncio.run() cannot be called from a running event loop. Inside async code you just await the coroutine. asyncio.run belongs at the top level of your program, once.

Awaiting inside a normal def. await is a syntax error outside async def. If you need async work from sync code, the boundary has to be crossed with asyncio.run at the entry point, not sprinkled in the middle.

Using a blocking client by accident. Import lists are worth auditing. If your async view imports requests, time.sleep, or a synchronous ORM, the server is not concurrent no matter how many async def keywords it contains. Under load this shows up as request latency that rises together for every user at once.

Sharing one client badly. Creating a new httpx.AsyncClient per request throws away connection pooling. Create one and reuse it, closing it on shutdown with async with or a lifespan hook.

Ignoring cancellation. When a client disconnects or a timeout fires, your task receives asyncio.CancelledError at its next await point. Swallowing it with a bare except Exception is usually wrong; in modern Python CancelledError inherits from BaseException so a plain except Exception will not catch it, but a bare except: will and that leaves zombie tasks running.

import asyncio

async def main():
    try:
        await asyncio.wait_for(slow_call(), timeout=5)
    except asyncio.TimeoutError:
        print("gateway did not answer in 5s")

Finally, debug mode is free and underused. Run with PYTHONASYNCIODEBUG=1 and Python will warn you when a coroutine blocks the loop for too long, which is the fastest way to find the one stray blocking call hiding in a large codebase.

Frequently Asked Questions

Is asyncio faster than threads in Python? Neither is faster at doing work; they differ in overhead and safety. Asyncio uses one thread and switches only at await points, so it handles thousands of idle network connections with very little memory. Threads cost more per connection but can run blocking libraries without any rewriting. For a few dozen blocking calls, threads are simpler. For thousands of concurrent sockets, asyncio wins.
Why does my async function return a coroutine object instead of a value? You called it without awaiting it. Calling an async def function only builds a coroutine object; the body runs when the event loop drives it. Add await in front if you are already inside an async function, or wrap it in asyncio.run() at the top level. Python also prints a RuntimeWarning saying the coroutine was never awaited, which is your clue.
Can I use asyncio with Django or Flask? Both support async views in recent versions, but the surrounding stack matters more than the keyword. If your database driver and ORM calls are synchronous, an async view still blocks. Frameworks built for async from the start, such as FastAPI on an ASGI server, make the whole path cooperative. Mixing a sync ORM into an async handler is the usual reason async brings no benefit.
Does async make my code use multiple CPU cores? No. A standard asyncio program runs on a single thread of a single process, so it uses one core. Concurrency here means overlapping waiting, not overlapping computation. To use several cores you need multiple processes, either through multiprocessing, a ProcessPoolExecutor driven from the loop, or by running several worker processes behind a load balancer.
What is the difference between a coroutine and a task? A coroutine is inert work that has not been scheduled. A task is a coroutine handed to the event loop with asyncio.create_task, so it starts running at the next opportunity whether or not you await it. Awaiting a coroutine directly runs it inline; awaiting a task waits for something already in progress. Tasks can also be cancelled, which plain coroutines cannot.