Know what, I have kept 🍕 pizza in the oven and I am writing this lesson while it bakes.
Back in the generators lesson I promised the waiter analogy would work overtime. Today it earns its salary.
The problem with doing one thing at a time
Imagine 3 requests hit our server, in order:
Request 1: Send a forgot password email. Talking to a mail server takes seconds.
Request 2: Fetch blog 23 from the database. Disk reads take milliseconds.
Request 3: Add 3 + 4. Nanoseconds.
In a synchronous world, request 3 waits for the other two to fully finish. If request 1 is sending 100 newsletter emails, poor request 3 starves until the client gives up with a timeout. It just wanted to add two numbers. 😢
Here is the insulting part: during those email and database waits, the CPU does nothing. A CPU works in nanoseconds, a disk in milliseconds, a network call in full seconds. Your processor is a Formula 1 car spending most of the race parked, waiting for the kitchen to finish cooking.
Seeing it in numbers
Say each blog summary comes from a slow AI service that takes 1 second to respond. We will fake the wait with sleep:
import timedef fetch_summary(blog_id: int) -> str: time.sleep(1) # pretend this is a slow API call return f"Summary of blog {blog_id}"start = time.time()summaries = [fetch_summary(i) for i in range(1, 4)]print(f"Took {time.time() - start:.2f} sec")# Took 3.00 secWhat it does ?
Three fetches, one second each, executed one after another. Total: 3 seconds.
During each
time.sleep(1), our program sits idle. It cannot start the next fetch, cannot do anything.
Enter async and await
Now the asynchronous version:
import asyncioimport timeasync def fetch_summary(blog_id: int) -> str: await asyncio.sleep(1) # a non-blocking wait return f"Summary of blog {blog_id}"async def main(): return await asyncio.gather( fetch_summary(1), fetch_summary(2), fetch_summary(3), )start = time.time()summaries = asyncio.run(main())print(f"Took {time.time() - start:.2f} sec")# Took 1.00 secLet's check this one out:
async defmakes the function a coroutine. Calling it does not run it, it hands you an object that is ready to run. Sounds familiar? Generators did the same thing.awaitandyieldare cousins: both pause a function and let something else use the time.await asyncio.sleep(1)means "I am waiting here, feel free to run other code meanwhile". That is the waiter dropping off your order and serving other tables instead of standing in the kitchen.asyncio.gather(...)starts all three coroutines together and collects the results. Three 1 second waits overlap into 1 second total.asyncio.run(main())starts the event loop, the manager that decides which paused coroutine gets to continue. You need it exactly once, at the top.
The golden rules
awaitonly works inside anasync deffunction.Only await things designed for it:
asyncio.sleep, async database drivers, async HTTP clients likehttpx. A regulartime.sleep(1)inside async code blocks the whole event loop, and every table in the restaurant waits. One blocking call ruins it for everyone. 🚨Async shines for I/O waits (network, disk, LLM calls). It does not speed up CPU work like number crunching, there is nothing to wait on.
Why FastAPI cares
FastAPI runs on an event loop. Declare an endpoint with async def and it can serve other requests while yours waits on the database or an LLM call. This is a big part of why FastAPI benchmarks so well, and it is essential for GenAI apps where every LLM call is seconds of pure waiting. Perfect overlap material.
Our Python toolbox is now full: type hints, generators, Pydantic, and async.
Now if you will excuse me, the oven just beeped. 🍕
