Python async await Explained: asyncio in Plain English
async and await let one Python thread juggle many waiting tasks: while one task waits on a timer or the network, another runs. Two one-second jobs can finish in about one second in total instead of two:
import asyncio, time
async def job(name):
await asyncio.sleep(1)
print(name, "done")
async def main():
start = time.perf_counter()
await asyncio.gather(job("A"), job("B"))
print(f"total: {time.perf_counter() - start:.1f}s")
asyncio.run(main())
A done
B done
total: 1.0s
Both jobs slept at the same time, so the total is about 1 second, not 2. The rest of this post explains why, and where the model breaks down.
What async and await actually do
asyncio is cooperative multitasking on a single thread. At its centre is the event loop, a scheduler that holds a list of tasks.
- The loop picks a task and runs it.
- The task runs normally until it hits
awaiton something that is not ready yet, like a timer or a network response. - At that point the task pauses and hands control back to the loop.
- The loop runs another task that is ready.
- When the timer fires or the data arrives, the paused task is resumed from where it stopped.
In the example, job("A") starts, reaches await asyncio.sleep(1) and pauses. The loop starts job("B"), which also pauses. Now both are waiting at once. After one second both wake up, print, and finish.
Nothing runs in parallel on the CPU. There is one thread, and tasks take turns. Async wins because most of the time was spent waiting, and waiting can overlap.
Coroutines are not ordinary functions
async def defines a coroutine function. Calling it does not run its body; it returns a coroutine object that describes the work:
import asyncio
async def greet():
return "hello"
c = greet()
print(type(c))
print(asyncio.run(c))
<class 'coroutine'>
hello
The body only runs when something drives the coroutine: await, asyncio.run, or a task. This leads to the most common async bug, forgetting await. The following is an intentional mistake:
import asyncio
async def save():
print("saved")
async def main():
save() # bug: no await
print("main finished")
asyncio.run(main())
"saved" is never printed. Instead Python prints RuntimeWarning: coroutine 'save' was never awaited and then "main finished". If you see that warning, look for a missing await.
asyncio.run: the entry point
asyncio.run(main()) creates an event loop, runs main() until it finishes, and closes the loop. Call it once, at the top of your program. Inside async code, you use await instead.
One browser-specific note: in PyRun lessons your code runs in a page that already has an event loop, so asyncio.run raises RuntimeError there. Use top-level await main() instead, as the async basics lesson explains. In a normal .py script, asyncio.run is correct.
Running tasks together: gather and TaskGroup
await job() on its own runs one coroutine and waits for it. To overlap work you need to start several before waiting. asyncio.gather does that and returns results in the order you passed them, not the order they finished:
import asyncio
async def fetch(n):
await asyncio.sleep(n / 10)
return n * 10
async def main():
results = await asyncio.gather(fetch(3), fetch(1), fetch(2))
print(results)
asyncio.run(main())
[30, 10, 20]
Python 3.11 added asyncio.TaskGroup, which is now the recommended way to start a group of tasks:
import asyncio
async def fetch(n):
await asyncio.sleep(n / 10)
return n * 10
async def main():
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(fetch(1))
t2 = tg.create_task(fetch(2))
print(t1.result(), t2.result())
asyncio.run(main())
10 20
When the async with block exits, every task has finished. The practical difference is error handling: if one task in a TaskGroup raises, the others are cancelled and the errors are raised together as an ExceptionGroup. With plain gather, the other tasks keep running after one fails unless you handle it yourself.
The time.sleep trap
asyncio.sleep pauses one task and lets the loop run others. time.sleep freezes the whole thread, and with it the event loop. Swap it into the first example:
import asyncio, time
async def bad_job(name):
time.sleep(1) # blocks the whole event loop
print(name, "done")
async def main():
start = time.perf_counter()
await asyncio.gather(bad_job("A"), bad_job("B"))
print(f"total: {time.perf_counter() - start:.1f}s")
asyncio.run(main())
A done
B done
total: 2.0s
About 2 seconds: the jobs ran one after the other. The same thing happens with any blocking call inside a coroutine, including requests.get, a slow file read or a heavy loop. The async keyword does not make blocking code non-blocking.
If you are stuck with a blocking library, push it onto a thread with asyncio.to_thread:
import asyncio, time
def legacy_download(name):
time.sleep(1) # a blocking library you cannot change
return f"{name} ok"
async def main():
start = time.perf_counter()
results = await asyncio.gather(
asyncio.to_thread(legacy_download, "A"),
asyncio.to_thread(legacy_download, "B"),
)
print(results, f"{time.perf_counter() - start:.1f}s")
asyncio.run(main())
['A ok', 'B ok'] 1.0s
Back to about 1 second. (This one needs a normal Python install; threads are not available in the browser.)
Timeouts
Never wait forever on the network. Python 3.11 added asyncio.timeout, a context manager that cancels whatever is inside it once the time is up:
import asyncio
async def slow_api():
await asyncio.sleep(5)
return "data"
async def main():
try:
async with asyncio.timeout(1):
print(await slow_api())
except TimeoutError:
print("gave up after 1 second")
asyncio.run(main())
gave up after 1 second
The program exits after about 1 second, not 5. On older Python versions, asyncio.wait_for(coro, timeout=1) does the same job.
Async HTTP: where it pays off
The classic use is many network requests at once. The popular requests library is blocking, so async code uses an async client such as httpx or aiohttp. Both are third-party (pip install httpx) and this example needs a real network connection, so run it locally rather than in the browser:
import asyncio
import httpx
URLS = [
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
]
async def main():
async with httpx.AsyncClient(timeout=10) as client:
responses = await asyncio.gather(*(client.get(u) for u in URLS))
print([r.status_code for r in responses])
asyncio.run(main())
[200, 200, 200]
Each URL takes about a second to respond, but the three requests wait together, so the whole batch takes roughly as long as the slowest one plus connection overhead, not three seconds. If HTTP itself is new, the HTTP fundamentals lesson covers requests, status codes and headers first.
When async helps, and when it does not
Async helps when the program spends most of its time waiting and there are many waits: web scrapers, API clients calling dozens of endpoints, chat servers, bots, web backends handling many slow connections.
Async does not help with CPU-bound work: image processing, number crunching, parsing huge files. There is still only one thread, and a busy loop never reaches an await. For that, use separate processes:
from concurrent.futures import ProcessPoolExecutor
def count_primes(limit):
count = 0
for n in range(2, limit):
if all(n % d for d in range(2, int(n ** 0.5) + 1)):
count += 1
return count
if __name__ == "__main__":
with ProcessPoolExecutor() as pool:
print(list(pool.map(count_primes, [50_000, 60_000])))
[5133, 6057]
Each limit is handled by its own process, so they can use separate CPU cores. (Processes need a local Python install too.)
Threads vs asyncio in one paragraph
Threads and asyncio both overlap waiting. Threads work with ordinary blocking libraries and the operating system decides when to switch, which means shared data needs locks. asyncio switches only at await, so there are fewer surprises, and thousands of tasks are cheap, but every library in the path must be async-aware. For a handful of blocking calls, a thread pool is simpler. For hundreds of concurrent connections, asyncio is the better fit. The threads and asyncio lesson compares them side by side.
Practise it
Work through the async basics lesson: its graded exercises have you write coroutines and gather them in the browser, using top-level await. Then copy the first example of this post into a local script, change asyncio.sleep to time.sleep, and watch the total time double. For quick experiments there is also the Python terminal and the browser editor. The first five lessons are free.