async and await let a single Python thread make progress on many tasks at once — not by computing in parallel, but by never sitting idle while one task waits. If your code talks to networks, databases, files or APIs, waiting usually dominates its runtime, and asyncio exists to reclaim that time. This tutorial covers the core of Python's async model: what async def actually defines, what await actually does, how asyncio.run, gather and create_task fit together, where async genuinely does not help, and the mistakes almost everyone makes in their first week. Every example is complete, uses only the standard library, and runs as written.
The problem async solves: waiting, not working
Most 'slow' programs are not slow because the CPU is busy. They are slow because they wait. An HTTP request to a typical API takes 100–500 ms, and during that time a synchronous program does nothing at all. Make ten such requests one after another and you spend several seconds, of which the CPU used perhaps a millisecond.
The standard mental model: a waiter in a restaurant does not stand at the kitchen hatch until one table's food is ready. They take the order, pass it to the kitchen, and serve other tables until the kitchen signals. One waiter, many tables. In asyncio, the waiter is your single thread, the tables are tasks, and the event loop decides who gets attention next. The kitchen — the network, the disk, the database — does its work without the waiter's involvement, which is exactly why the model works.
Two points follow directly. First, async is concurrency by interleaving, not parallelism: only one piece of your Python code runs at any instant. Second, async only wins when there is real waiting to overlap. Code that computes constantly has nothing to hand to the kitchen.
async def returns a coroutine, not a result
Defining a function with async def makes it a coroutine function. Calling it does not run its body. It builds and returns a coroutine object: a description of work, paused before its first line.
import asyncio
async def add(a, b):
print('running')
return a + b
coro = add(2, 3)
print(coro) # <coroutine object add at 0x...> — nothing has run
print(asyncio.run(coro)) # prints 'running', then 5
Nothing inside add executes until something drives the coroutine — here, asyncio.run. A useful analogy: a coroutine function is a recipe, and calling it hands you the recipe card, not the meal. This one fact explains the most common async bug in existence, the 'coroutine was never awaited' warning, which we return to below.
await: suspend here, continue when ready
await something does two things. It pauses the current coroutine until something produces a result, and — critically — it hands control back to the event loop in the meantime, so other tasks can run. That handover is the entire mechanism of asyncio. No await, no concurrency.
You can only use await inside an async def body; at module level in a normal script it is a SyntaxError. And you can only await an awaitable: a coroutine, an asyncio.Task, an asyncio.Future, or any object implementing __await__. In everyday code that means coroutines and tasks. Awaiting a coroutine runs it from start to finish, including any suspensions of its own — coroutines call coroutines all the way down until something like a network read genuinely yields to the loop.
asyncio.run() is the entry point
Coroutines need an event loop to drive them, and asyncio.run is how a program gets one. It creates a fresh loop, runs the coroutine you give it until it completes, then closes the loop. A program calls it once, at the top.
import asyncio
async def main():
print('start')
await asyncio.sleep(1)
print('one second later')
asyncio.run(main())
asyncio.sleep is the async counterpart of time.sleep: it asks the loop to resume this coroutine after a delay and gets out of the way. Because it waits without blocking, it is the standard stand-in for network I/O in examples, and the rest of this article uses it that way.
Sequential await vs asyncio.gather
Here is the trap that makes people conclude async 'does not work': awaiting things one after another is still sequential. You get the syntax of async with none of the benefit.
import asyncio
import time
async def fetch(name, delay):
await asyncio.sleep(delay) # stands in for a network call
return f'{name} done'
async def sequential():
start = time.perf_counter()
a = await fetch('a', 1)
b = await fetch('b', 1)
c = await fetch('c', 1)
print(a, b, c)
print(f'sequential: {time.perf_counter() - start:.2f}s')
async def concurrent():
start = time.perf_counter()
results = await asyncio.gather(
fetch('a', 1), fetch('b', 1), fetch('c', 1)
)
print(*results)
print(f'concurrent: {time.perf_counter() - start:.2f}s')
async def main():
await sequential()
await concurrent()
asyncio.run(main())
The sequential version reports about 3.00 s; the concurrent one about 1.00 s. In the first, each await waits for its coroutine to finish before the next one even starts, so the delays add up. asyncio.gather starts all three, lets their waits overlap, and completes when the slowest finishes. It returns results in the order you passed the coroutines, not the order they finished, so pairing results with inputs stays easy. Paste the example into the free browser editor and vary the delays: the concurrent total tracks the maximum delay, not the sum.
asyncio.create_task: start now, collect later
gather starts and finishes a batch together. Sometimes you want fire-and-start semantics instead: begin a piece of work immediately, carry on with other things, and collect the result later.
import asyncio
async def send_metrics():
await asyncio.sleep(1) # pretend this posts to a metrics server
print('metrics sent')
async def main():
task = asyncio.create_task(send_metrics()) # scheduled immediately
print('request handled') # main carries on without waiting
await asyncio.sleep(0.5)
print('more work done')
await task # collect it before the program exits
asyncio.run(main())
create_task wraps a coroutine in a Task and schedules it on the loop straight away; it begins running the next time the current code suspends. Two rules keep tasks safe. Keep a reference — the loop holds only weak references, so a task nothing points to can be garbage-collected mid-flight. And await the task (or cancel it) before the program ends, otherwise it is destroyed unfinished when the loop closes.
When async does not help
Async buys you nothing on CPU-bound work: hashing data already in memory, resizing images, parsing gigabytes of JSON. There is no waiting to overlap — the CPU is genuinely the bottleneck, and asyncio's single thread just runs one computation with extra ceremony.
Threads do not rescue CPU-bound Python either. CPython's Global Interpreter Lock (GIL) allows only one thread to execute Python bytecode at a time, so threads interleave CPU work rather than parallelise it (they do help when a blocking library waits on I/O, because the GIL is released during those waits). Async does not change any of this: an event loop is cooperative scheduling on one thread. For real parallelism on CPU-bound work, use multiple processes — multiprocessing or concurrent.futures.ProcessPoolExecutor — where each process has its own interpreter and its own GIL.
Rule of thumb: waiting → asyncio; computing → processes; a blocking library you cannot replace → await asyncio.to_thread(blocking_call).
async with and async for
These are the async counterparts of context managers and for loops, for objects whose setup, teardown, or next-item step may itself need to wait. An HTTP client session opens connections inside async with; a database driver streams rows through async for. The standard library is enough to see the shape:
import asyncio
async def countdown(n):
while n > 0:
await asyncio.sleep(0.2) # an async generator may await between yields
yield n
n -= 1
async def main():
lock = asyncio.Lock()
async with lock: # suspends (never blocks) if the lock is held
async for n in countdown(3):
print(n)
asyncio.run(main())
async with lock suspends this task if another task holds the lock, instead of blocking the thread. async for pulls items from an asynchronous generator — one that may await between yields — pausing the loop body while each item is produced. Ordinary with and for cannot suspend, which is why the separate syntax exists.
Common mistakes
Forgetting await
import asyncio
async def greet():
return 'hello'
async def main():
greet() # bug: builds a coroutine, never runs it
print(await greet()) # correct: prints hello
asyncio.run(main())
Calling greet() without await creates a coroutine object and throws it away; the body never runs. Python notices at garbage-collection time and prints RuntimeWarning: coroutine 'greet' was never awaited. If an async function appears to have no effect, check its call sites first.
Calling asyncio.run inside a running loop
asyncio.run refuses to nest. Called from code already running under an event loop, it raises RuntimeError: asyncio.run() cannot be called from a running event loop. This bites people in Jupyter, which runs its own loop. The rule: asyncio.run appears exactly once, at the top of a program; everywhere else, simply await the coroutine. In notebooks, top-level await already works.
Blocking the loop with time.sleep
import asyncio
import time
async def heartbeat():
for _ in range(4):
await asyncio.sleep(0.25)
print('beat')
async def blocking():
time.sleep(1) # freezes the whole event loop
print('blocking done')
async def polite():
await asyncio.sleep(1) # waits without freezing anything
print('polite done')
async def main():
await asyncio.gather(heartbeat(), blocking()) # beats arrive late, in a burst
await asyncio.gather(heartbeat(), polite()) # beats arrive on time
asyncio.run(main())
Everything in asyncio shares one thread. time.sleep(1) blocks that thread, so every task freezes: in the first gather, the heartbeat goes silent for a full second, then the missed beats fire in a burst. The same applies to any blocking call — requests.get, heavy computation, blocking file reads. Use async equivalents, or push the call into a thread with asyncio.to_thread.
Awaiting sequentially in a loop when you needed gather
import asyncio
import time
async def fetch(i):
await asyncio.sleep(0.5)
return i * i
async def main():
start = time.perf_counter()
slow = [await fetch(i) for i in range(5)]
print(f'loop: {time.perf_counter() - start:.2f}s') # ~2.50s
start = time.perf_counter()
fast = await asyncio.gather(*(fetch(i) for i in range(5)))
print(f'gather: {time.perf_counter() - start:.2f}s') # ~0.50s
assert slow == fast
asyncio.run(main())
The comprehension awaits each call before starting the next: five half-second waits run back to back, about 2.5 s in total. gather overlaps them into roughly 0.5 s. Whenever the iterations of a loop are independent I/O operations, unpack them into gather with *.
Practise it
Reading about the event loop is not the same as watching a gather finish three times faster than a loop of awaits. The graded exercises for this topic are in the interactive lesson at the top of this page, which is part of PyRun Pro. If you are new to the course, the first lessons of the track are free — and every example in this article runs unchanged in the browser, on real CPython, with no setup.