Q1Given async def hello(): return 'Hi', what does hello() return?
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.
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.
- An independent execution unit from the OS's view
- Has its own memory space
- The unit that actually runs code inside a process
- Plain Python typically has just one running
- Switches turns inside one thread
- You can create as many as you want with
async / await
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
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
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.
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 function — calling 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
| Element | Meaning | Notes |
|---|---|---|
| async def f(): | Defines a coroutine function | Calling it does not run the body |
| f() | Creates a coroutine object | Needs await to actually run |
| await f() | Waits for completion, switching to others | Only 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 level | Standard 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.
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.
Knowledge Check
Answer each question one by one.
Q2Inside an async function, which one "waits N seconds while switching to another task"?
Q3Which workload benefits most from async / await?