Learn by reading through in order

async / await Basics — Make Programs Faster by Using Wait Time

Learn how coroutines, the event loop, and asyncio.sleep work, and how async def / await hand off I/O wait time to other tasks to speed your program up.

async / await is a mechanism for doing other work while waiting on I/O (Input/Output — file reads, network calls, DB queries, and other operations dominated by wait time). It can speed up tasks like hitting a Web API 100 times, without leaving a single thread. This article walks through three core ideas: coroutines, the event loop, and asyncio.sleep.

About running code here

async / await is all about timing, but this site's runner buffers print output and shows it all at once after the script finishes. Real-time output and elapsed-time feel won't match a real Python environment. The article uses diagrams to make the internal behavior clear, but if you want to see print flow live or feel the actual timing, run it in your local Python environment with asyncio.run.

Where async / await shines
Hit 100 Web APIsconcurrentlyRun DB queriesconcurrentlyProcess file I/Oin parallel pathsWeb scrapingmany pages at once
Best for wait-time-dominated work. async switches to another task while CPU is idle (waiting on I/O / network / sleep), keeping the program on a single thread while making the whole thing faster.

Process and Thread — context for async / await

Before diving into async / await, let's pin down process and thread.

A process (one running program from the OS's view) is an independent execution unit with its own memory space. Run a Python script and you get one process; run another in a different terminal and you have two.

A thread (the flow that actually runs code inside a process) is the unit that uses CPU to advance code. A process can have multiple threads, but a typical Python program runs as 1 process + 1 thread, and a wait like time.sleep stops that thread.

Process / Thread / Coroutine — nested layers
Process (one Python app)
  • An independent execution unit from the OS's view
  • Has its own memory space
Thread (a flow of execution)
  • The unit that actually runs code inside a process
  • Plain Python typically has just one running
Coroutine (an async def function)
  • Switches turns inside one thread
  • You can create as many as you want with async / await
Three levels of nesting: a process contains a thread, which contains coroutines. async / await only schedules the innermost coroutines — it never adds threads or CPUs. That's the key difference from threading (adds threads) and multiprocessing (adds processes).

async / await only handles the innermost coroutines. It switches between coroutines on the same thread, never adding threads or CPUs — it just hands control to another coroutine while the CPU is idle waiting on I/O. To run threads truly in parallel, use threading; to add processes, use multiprocessing (covered in the next two articles).

What is a coroutine — the star of async / await

A coroutine (a function / object defined with async def that can pause at internal await points) is at the heart of async / await. A regular function runs straight through to the end when called, but a coroutine can pause at every internal await and resume when the event loop tells it to. That's the foundation async is built on.

Calling an async def function does not run its body — it just returns a coroutine object (e.g., coro = hello()). The body only starts running when you await coro or pass it to asyncio.run(coro).

import asyncio

async def hello():
    return "Hi"

# Calling it doesn't run the body
coro = hello()
print(coro)             # <coroutine object hello at 0x...>
                        # ↑ 0x... is a memory address.
                        #   It just means "a coroutine was created"

# await actually runs it
print(await coro)       # Hi
Coroutine states
async def f()definedf() calledcoroutine createdawait f()→ starts runningreturn→ done
Calling an async def function doesn't run the body — it just returns a coroutine object. await actually starts execution, and return finishes it. Internal await points pause and resume in between.

When you write await some_io(), the coroutine pauses and switches to another task, and other coroutines can run while it waits — that's the defining feature of async.

What is the event loop — the scheduler that switches coroutines

The event loop (a scheduler that switches coroutines in turn) is what asyncio runs under the hood. It loops endlessly: pick a coroutine from the queue → run it → switch to another at await → put completed waits back on the queue.

import asyncio

async def main():
    print("start")
    await asyncio.sleep(0)   # sleep(0) doesn't really wait,
                             #   it just marks "OK to switch here"
    print("end")

# asyncio.run starts the loop → runs main → closes the loop when done
asyncio.run(main())
# Output:
# start
# end
How the event loop works
Pick fromthe queueRun thecoroutineOn await,switch outI/O done →back on queue
The event loop pulls a runnable coroutine from the queue and runs it, switches to another coroutine when one hits await, and puts a coroutine back on the queue when its I/O finishes — looping forever on a single thread.

All of this happens on a single thread — no extra CPU cores, just filling the idle wait time with another coroutine. In this site's browser-based Python, the event loop is already running, so you can write await directly at the top level without calling asyncio.run(...).

Why async / await — switching to another task during waits

In synchronous (sync) code (the regular line-by-line style), time.sleep(1) blocks the program — the CPU is idle, but the program is frozen. The same applies to Web API responses, DB queries, and file I/O completion.

async / await lets you switch to another task wherever you write "wait", staying on the same thread but switching the moment a wait kicks in.

# requests = sync HTTP client (one call at a time)
# httpx    = async-capable HTTP client (await calls in parallel)
import requests, asyncio, httpx

# Sync: hit 3 APIs one by one → 3 seconds total
def fetch_users_sync():
    r1 = requests.get("https://api.example.com/users/1")  # ← waits 1 sec
    r2 = requests.get("https://api.example.com/users/2")  # ← another 1 sec
    r3 = requests.get("https://api.example.com/users/3")  # ← another 1 sec
    return [r1.json(), r2.json(), r3.json()]

# Async: hit all 3 in parallel → finishes in ~1 sec (the slowest one)
async def fetch_users_async():
    async with httpx.AsyncClient() as client:
        r1, r2, r3 = await asyncio.gather(
            client.get("https://api.example.com/users/1"),
            client.get("https://api.example.com/users/2"),
            client.get("https://api.example.com/users/3"),
        )
        return [r1.json(), r2.json(), r3.json()]

asyncio.gather details come next

asyncio.gather(...) used here runs multiple coroutines concurrently and waits for all results. Detailed semantics — return values, exception handling — are covered in the next article: asyncio Tasks.

async — multiple tasks taking turns
A runningB waitingC waitingA: await→ switch outB runningC waitingA waitingB: await→ switch outC runningswitchswitch
Three coroutines share one CPU on the same thread. Each task switches out at await when it starts waiting, and the next one runs. Wait time advances in parallel without ever leaving the single thread — that's the heart of async.
Concurrent vs Parallel
ConcurrentSwitch betweentasks on one CPUasync / await(this article)ParallelMultiple CPU coresrun at oncethreading /multiprocessing(next articles)
Concurrent = switching between tasks on one CPU (what async / await gives you). Parallel = multiple CPU cores actually running at the same time (achieved with threading / multiprocessing). async won't speed up code that uses 100% CPU.

Concurrent, not parallel

async / await never adds CPU — code that maxes out the CPU (heavy computation) won't speed up. It only fills idle CPU time during "I/O wait / network wait / sleep" by switching to another task. This is concurrent execution, not true parallel execution. To actually run on multiple cores, you need threading or multiprocessing — covered in the next two articles.

async def and await — the basics

A function defined with async def is called a coroutine functioncalling it doesn't run the body, it just returns a coroutine object. To actually run it, await it or pass it to asyncio.run().

await x means "wait for x to finish, switching to another task in the meantime". x can be one of three things: a coroutine (what this article focuses on), a Task, or a Future (the Task object covered next article, plus the completion-notification object it uses internally) — in everyday code, you'll mostly use coroutines or Tasks.

import asyncio

# Define a coroutine function with async def
async def hello():
    return "Hi"

# Calling it just returns a coroutine object (the body doesn't run)
print(hello())                    # <coroutine object hello at 0x...>

# await runs it (top-level await works in this browser environment)
result = await hello()
print(result)                     # Hi

# In a real Python script, wrap it with asyncio.run()
# print(asyncio.run(hello()))     # Hi
async def and await
async def hello(): return 'Hi'hello() result= a coroutine(body not run)await hello()→ returns 'Hi'call onlyawait
Define with async defcalling alone returns a coroutine object with the body unexecuted → await actually runs it and yields the result. That's the minimum rule of async / await.
ElementMeaningNotes
async def f():Defines a coroutine functionCalling it does not run the body
f()Creates a coroutine objectNeeds await to actually run
await f()Waits for completion, switching to othersOnly legal inside an async function
asyncio.sleep(N)Wait N seconds (yielding while waiting)Doesn't block like time.sleep
asyncio.run(f())Runs from the top levelStandard entry point in real Python

Without await, the coroutine never runs

If you write hello() alone, the body never runs, and you'll see a warning like <coroutine object hello at 0x...> in the console. Always either await hello() or run it via asyncio.run(hello()). "Calling" and "running" are two different things in async — keep them straight.

Write a small async function that simulates an API call, then run it with await. We'll use asyncio.sleep(0.5) to fake a 0.5-second response time.

① Add import asyncio.

② Define async def fetch_user(user_id): — print f"user {user_id} start", await asyncio.sleep(0.5) to wait 0.5 sec, print f"user {user_id} done", then return f"User{user_id}".

③ Run with result = await fetch_user(1) and store the value (top-level await works here).

④ Print f"result: {result}".

(Run successfully and the explanation will appear.)

Python Editor

Run code to see output

Concurrent execution shares the wait — the payoff of asyncio

asyncio.sleep(seconds) is the async version of sleep — it switches to another task while waiting. Combined with asyncio.gather (next article) to run multiple coroutines at once, all sleeps progress concurrently, so the total time becomes the longest single sleep (not the sum, like time.sleep would give you).

Internally, asyncio.sleep(N) registers "resume this coroutine in N seconds" with the loop and immediately switches out. By contrast, time.sleep(N) is an OS call that completely blocks the CPU — the loop stops too, and no other coroutines can run. Using time.sleep inside an async function defeats the whole point of async, so be careful.

Inside asyncio.sleep
awaitasyncio.sleep(1)Tell loop:"wake me in 1s"Meanwhile othercoroutines run1 sec later→ resume
asyncio.sleep(N) registers "resume in N seconds" with the event loop and switches out. During that window, other coroutines run. After N seconds the coroutine goes back on the queue and resumes.
time.sleep vs asyncio.sleep
time.sleep(1)(sync)Blocks CPUfor 1 secondOther asynctasks freeze tooasyncio.sleep(1)(async)Yields to loopfor 1 secondOther taskscan progress
time.sleep fully blocks the CPU, so other async tasks can't run. asyncio.sleep switches to another task, letting them progress — always use asyncio.sleep inside async functions.

Call the same fetch_user 3 times in parallel and watch a 1.5-second sequential job finish in 0.5 seconds. We use asyncio.gather for parallel execution (full details in the next article).

① Add import asyncio.

② Define async def fetch_user(user_id): — same as the previous exercise (start print → await asyncio.sleep(0.5) → done print → return).

③ Run with results = await asyncio.gather(fetch_user(1), fetch_user(2), fetch_user(3)) to fire 3 calls in parallel.

④ Print the result list as f"results: {results}".

Python Editor

Run code to see output
QUIZ

Knowledge Check

Answer each question one by one.

Q1Given async def hello(): return 'Hi', what does hello() return?

Q2Inside an async function, which one "waits N seconds while switching to another task"?

Q3Which workload benefits most from async / await?