async/await: Concurrency Without Threads (and Why It's Not the Same Thing)
Priya fears async code. Today it clicks: async def creates coroutines, await pauses, asyncio.gather runs them concurrently. Not threading. One chef, never idle.
Okay. I've been afraid of the async code in our pipeline for months. But based on what you showed me yesterday with threading — async def doesn't make anything parallel. It just means "this function can pause and let other things run while it waits." Is that really it?
That is really it. An async function is the chef who puts the salmon in the oven and moves on to plating instead of standing in front of the oven door. The salmon is not cooking faster. The chef is not cloned. But the kitchen is not idle.
So when I see async def fetch_order() in our codebase, it means the function is going to pause. And when I call it with await, Python pauses there until the result comes back. But if I'm calling it inside a for loop with await — like for order in orders: await fetch_order(order) — then each order waits for the previous one to finish. I've been looking at code exactly like that. It's in the pipeline. That's why it's so slow.
Stop right there. You just diagnosed your production bug. That for loop with await inside it is the chef cooking one dish at a time with a fully equipped kitchen sitting empty.
I know the code you mean. Yesterday I looked at it for the hundredth time and thought "I'm missing something obvious." But if threading is about multiple chefs (and we can't do that because of the GIL) and multiprocessing is opening a second kitchen (and that's too expensive for I/O), then async is... one chef who never stands idle. That chef can start multiple orders at once.
That chef needs a way to start all those orders simultaneously. That is what asyncio.gather() does. Instead of a loop that awaits each order one at a time, gather takes a list of coroutines and runs them all concurrently, waiting for all of them to finish.
Let me show you the pattern. First, the slow version — the one that's in your codebase now:
import asyncio
async def fetch_order(order_id):
# Simulates fetching from an API
await asyncio.sleep(1) # I/O wait
return {"id": order_id, "total": 99.99}
async def process_orders_sequential(order_ids):
results = []
for order_id in order_ids:
result = await fetch_order(order_id) # Wait for each one
results.append(result)
return results
# Usage
order_ids = ["ORD-001", "ORD-002", "ORD-003"]
results = asyncio.run(process_orders_sequential(order_ids))
Timing: 3 seconds. Three orders, one second each, one at a time.
Now the fast version — the one using gather:
async def process_orders_async(order_ids):
# Create coroutines without awaiting yet
coroutines = [fetch_order(order_id) for order_id in order_ids]
# gather runs them all concurrently and waits for all to finish
results = await asyncio.gather(*coroutines)
return results
# Usage
results = asyncio.run(process_orders_async(order_ids))
Timing: 1 second. Three orders, one second each, all at once. The kitchen is never idle.
Wait. The only difference is the for loop. I remove the for loop, create all the coroutines as a list, pass them to gather, and suddenly it runs concurrently? That's the fix?
That is the fix. The insight is knowing what await does and when. await fetch_order(order_id) calls the function AND waits for it. Creating the coroutine object without awaiting — fetch_order(order_id) — does NOT call it yet. It just creates a coroutine object. Gather calls all of them.
Okay. When I write fetch_order(order_id) without await, I get a coroutine object. It's like holding a blueprint for work that hasn't started. Then asyncio.gather() looks at all the blueprints and says "run all of these at the same time."
Exactly. An async function returns a coroutine. A coroutine is a special object that knows how to run. await means "run this coroutine and pause here until it finishes." gather means "run all these coroutines concurrently and pause here until all of them finish."
Let's look at what actually happens inside asyncio:
import asyncio
async def fetch_order(order_id):
print(f"Starting {order_id}")
await asyncio.sleep(1)
print(f"Done {order_id}")
return {"id": order_id, "total": 99.99}
async def main():
print("Sequential:")
result1 = await fetch_order("ORD-001") # Starts, waits, done
result2 = await fetch_order("ORD-002") # Starts, waits, done
# Takes 2 seconds
print("Concurrent:")
results = await asyncio.gather(
fetch_order("ORD-001"), # Created but not started
fetch_order("ORD-002"), # Created but not started
) # Both start now, both pause on sleep, both finish in parallel
# Takes 1 second
asyncio.run(main())
Output:
Sequential:
Starting ORD-001
Done ORD-001
Starting ORD-002
Done ORD-002
Concurrent:
Starting ORD-001
Starting ORD-002
Done ORD-001
Done ORD-002
Watch the timing. Sequential prints take 2 seconds. Concurrent prints show both starting immediately, both finishing at the same time, taking 1 second total.
They both start at the same moment. And since they're both just waiting on I/O — the API call — one doesn't block the other. Async is perfect for this.
And now you know why it's called async. It's not "asynchronous" in the sense of running in parallel — there is still one thread, one chef. It's asynchronous in the sense that the waiting is decoupled from the execution. You don't wait for one order to finish before starting the next. They all start, they all wait on I/O, they all finish together.
Let me show you the production pattern — the one that would fix your pipeline:
async def process_orders_async(orders):
"""
Process multiple orders concurrently using asyncio.gather.
orders: list of order dicts with 'id' key
Returns: list of enriched order dicts
"""
# Create coroutine for each order without awaiting yet
coroutines = [fetch_order(order["id"]) for order in orders]
# Run all concurrently
results = await asyncio.gather(*coroutines)
return results
# At the top of your module
async def main():
orders = [
{"id": "ORD-001", "customer": "Priya", "total": 89.99},
{"id": "ORD-002", "customer": "Amir", "total": 124.50},
{"id": "ORD-003", "customer": "Jordan", "total": 156.00},
]
results = await process_orders_async(orders)
return results
if __name__ == "__main__":
asyncio.run(main())
This is it. This is the pattern. Every async I/O function you want to parallelise fits this mold: create the coroutines, gather them, await the result.
So I'm looking at our pipeline right now, mentally. The enrich_order() function calls fetch_pricing() and fetch_shipping() and fetch_inventory(). All three are async. All three wait on APIs. The current code does:
pricing = await fetch_pricing(order_id)
shipping = await fetch_shipping(order_id)
inventory = await fetch_inventory(order_id)
They're sequential. But they don't depend on each other. I could gather them. All three calls could happen at the same time.
Now you see the pipeline problem. You have been making network calls one at a time when you could make them all at once. That is not a code quality issue. That is a design error.
So I rewrite it like this:
async def enrich_order(order_id):
pricing, shipping, inventory = await asyncio.gather(
fetch_pricing(order_id),
fetch_shipping(order_id),
fetch_inventory(order_id),
)
return {
"id": order_id,
"pricing": pricing,
"shipping": shipping,
"inventory": inventory,
}
Five lines. And suddenly that function is waiting on all three APIs in parallel instead of serial.
That is not just correct — that is the fix. That right there is the performance improvement you have been looking for. And now that you know the pattern, you can apply it everywhere in the pipeline where you have independent I/O calls.
What about error handling? If one of those three API calls fails, what does gather do?
Good question. By default, if one coroutine raises an exception, gather cancels the others and raises that exception to you. That can be a problem in pipelines — you might want partial results. You can pass return_exceptions=True to gather, and it will return exceptions as values in the results list instead of raising them:
async def enrich_order_safe(order_id):
results = await asyncio.gather(
fetch_pricing(order_id),
fetch_shipping(order_id),
fetch_inventory(order_id),
return_exceptions=True, # Catch exceptions, return as values
)
# results is now a list that might contain Exception objects
pricing, shipping, inventory = results
if isinstance(pricing, Exception):
# Handle pricing fetch failure
pricing = get_default_pricing()
# ... etc
return {"id": order_id, "pricing": pricing, ...}
With return_exceptions=True, the gather completes even if some calls fail. You get a mixed list of results and exceptions, and you decide what to do with each one.
That's production-ready. One fetch fails, you don't crash the whole order. You recover with a default. Perfect for the pipeline.
Perfect for the pipeline. And notice: you wrote that logic yourself. You did not copy it from Amir's code. You understood the mechanism and applied it to a real problem.
So to summarize: async def creates a function that can pause. await pauses and waits for the result. asyncio.gather() takes multiple coroutines and runs them all at the same time, waiting for all of them. No threads, no multiprocessing. One thread, one chef, but the kitchen is never idle.
That is the complete picture. Async is concurrency through cooperation, not parallelism through cloning. The chef pauses when there is nothing to do — when something is waiting on I/O — and picks up another task. As soon as I/O completes, the chef resumes. Multiple I/O operations can overlap. CPU time never overlaps. That is why async is perfect for I/O-bound work and terrible for CPU-bound work.
I'm opening a PR for the pipeline fix this week. It's the pattern I just described — gather all the independent fetches and await them together. Multiple similar patterns throughout the pipeline. I've been staring at this code for months. Today it finally makes sense.
Before you do, you need to understand cancellation. What happens when a user disconnects while gather is still running? What happens when a gather in the middle of a long operation times out? These are edge cases that will show up in production. That is your next lesson — asyncio patterns. Gather is the foundation. Error handling, cancellation, and timeouts are how you make it robust.
Practice your skills
Sign up to write and run code in this lesson.
async/await: Concurrency Without Threads (and Why It's Not the Same Thing)
Priya fears async code. Today it clicks: async def creates coroutines, await pauses, asyncio.gather runs them concurrently. Not threading. One chef, never idle.
Okay. I've been afraid of the async code in our pipeline for months. But based on what you showed me yesterday with threading — async def doesn't make anything parallel. It just means "this function can pause and let other things run while it waits." Is that really it?
That is really it. An async function is the chef who puts the salmon in the oven and moves on to plating instead of standing in front of the oven door. The salmon is not cooking faster. The chef is not cloned. But the kitchen is not idle.
So when I see async def fetch_order() in our codebase, it means the function is going to pause. And when I call it with await, Python pauses there until the result comes back. But if I'm calling it inside a for loop with await — like for order in orders: await fetch_order(order) — then each order waits for the previous one to finish. I've been looking at code exactly like that. It's in the pipeline. That's why it's so slow.
Stop right there. You just diagnosed your production bug. That for loop with await inside it is the chef cooking one dish at a time with a fully equipped kitchen sitting empty.
I know the code you mean. Yesterday I looked at it for the hundredth time and thought "I'm missing something obvious." But if threading is about multiple chefs (and we can't do that because of the GIL) and multiprocessing is opening a second kitchen (and that's too expensive for I/O), then async is... one chef who never stands idle. That chef can start multiple orders at once.
That chef needs a way to start all those orders simultaneously. That is what asyncio.gather() does. Instead of a loop that awaits each order one at a time, gather takes a list of coroutines and runs them all concurrently, waiting for all of them to finish.
Let me show you the pattern. First, the slow version — the one that's in your codebase now:
import asyncio
async def fetch_order(order_id):
# Simulates fetching from an API
await asyncio.sleep(1) # I/O wait
return {"id": order_id, "total": 99.99}
async def process_orders_sequential(order_ids):
results = []
for order_id in order_ids:
result = await fetch_order(order_id) # Wait for each one
results.append(result)
return results
# Usage
order_ids = ["ORD-001", "ORD-002", "ORD-003"]
results = asyncio.run(process_orders_sequential(order_ids))
Timing: 3 seconds. Three orders, one second each, one at a time.
Now the fast version — the one using gather:
async def process_orders_async(order_ids):
# Create coroutines without awaiting yet
coroutines = [fetch_order(order_id) for order_id in order_ids]
# gather runs them all concurrently and waits for all to finish
results = await asyncio.gather(*coroutines)
return results
# Usage
results = asyncio.run(process_orders_async(order_ids))
Timing: 1 second. Three orders, one second each, all at once. The kitchen is never idle.
Wait. The only difference is the for loop. I remove the for loop, create all the coroutines as a list, pass them to gather, and suddenly it runs concurrently? That's the fix?
That is the fix. The insight is knowing what await does and when. await fetch_order(order_id) calls the function AND waits for it. Creating the coroutine object without awaiting — fetch_order(order_id) — does NOT call it yet. It just creates a coroutine object. Gather calls all of them.
Okay. When I write fetch_order(order_id) without await, I get a coroutine object. It's like holding a blueprint for work that hasn't started. Then asyncio.gather() looks at all the blueprints and says "run all of these at the same time."
Exactly. An async function returns a coroutine. A coroutine is a special object that knows how to run. await means "run this coroutine and pause here until it finishes." gather means "run all these coroutines concurrently and pause here until all of them finish."
Let's look at what actually happens inside asyncio:
import asyncio
async def fetch_order(order_id):
print(f"Starting {order_id}")
await asyncio.sleep(1)
print(f"Done {order_id}")
return {"id": order_id, "total": 99.99}
async def main():
print("Sequential:")
result1 = await fetch_order("ORD-001") # Starts, waits, done
result2 = await fetch_order("ORD-002") # Starts, waits, done
# Takes 2 seconds
print("Concurrent:")
results = await asyncio.gather(
fetch_order("ORD-001"), # Created but not started
fetch_order("ORD-002"), # Created but not started
) # Both start now, both pause on sleep, both finish in parallel
# Takes 1 second
asyncio.run(main())
Output:
Sequential:
Starting ORD-001
Done ORD-001
Starting ORD-002
Done ORD-002
Concurrent:
Starting ORD-001
Starting ORD-002
Done ORD-001
Done ORD-002
Watch the timing. Sequential prints take 2 seconds. Concurrent prints show both starting immediately, both finishing at the same time, taking 1 second total.
They both start at the same moment. And since they're both just waiting on I/O — the API call — one doesn't block the other. Async is perfect for this.
And now you know why it's called async. It's not "asynchronous" in the sense of running in parallel — there is still one thread, one chef. It's asynchronous in the sense that the waiting is decoupled from the execution. You don't wait for one order to finish before starting the next. They all start, they all wait on I/O, they all finish together.
Let me show you the production pattern — the one that would fix your pipeline:
async def process_orders_async(orders):
"""
Process multiple orders concurrently using asyncio.gather.
orders: list of order dicts with 'id' key
Returns: list of enriched order dicts
"""
# Create coroutine for each order without awaiting yet
coroutines = [fetch_order(order["id"]) for order in orders]
# Run all concurrently
results = await asyncio.gather(*coroutines)
return results
# At the top of your module
async def main():
orders = [
{"id": "ORD-001", "customer": "Priya", "total": 89.99},
{"id": "ORD-002", "customer": "Amir", "total": 124.50},
{"id": "ORD-003", "customer": "Jordan", "total": 156.00},
]
results = await process_orders_async(orders)
return results
if __name__ == "__main__":
asyncio.run(main())
This is it. This is the pattern. Every async I/O function you want to parallelise fits this mold: create the coroutines, gather them, await the result.
So I'm looking at our pipeline right now, mentally. The enrich_order() function calls fetch_pricing() and fetch_shipping() and fetch_inventory(). All three are async. All three wait on APIs. The current code does:
pricing = await fetch_pricing(order_id)
shipping = await fetch_shipping(order_id)
inventory = await fetch_inventory(order_id)
They're sequential. But they don't depend on each other. I could gather them. All three calls could happen at the same time.
Now you see the pipeline problem. You have been making network calls one at a time when you could make them all at once. That is not a code quality issue. That is a design error.
So I rewrite it like this:
async def enrich_order(order_id):
pricing, shipping, inventory = await asyncio.gather(
fetch_pricing(order_id),
fetch_shipping(order_id),
fetch_inventory(order_id),
)
return {
"id": order_id,
"pricing": pricing,
"shipping": shipping,
"inventory": inventory,
}
Five lines. And suddenly that function is waiting on all three APIs in parallel instead of serial.
That is not just correct — that is the fix. That right there is the performance improvement you have been looking for. And now that you know the pattern, you can apply it everywhere in the pipeline where you have independent I/O calls.
What about error handling? If one of those three API calls fails, what does gather do?
Good question. By default, if one coroutine raises an exception, gather cancels the others and raises that exception to you. That can be a problem in pipelines — you might want partial results. You can pass return_exceptions=True to gather, and it will return exceptions as values in the results list instead of raising them:
async def enrich_order_safe(order_id):
results = await asyncio.gather(
fetch_pricing(order_id),
fetch_shipping(order_id),
fetch_inventory(order_id),
return_exceptions=True, # Catch exceptions, return as values
)
# results is now a list that might contain Exception objects
pricing, shipping, inventory = results
if isinstance(pricing, Exception):
# Handle pricing fetch failure
pricing = get_default_pricing()
# ... etc
return {"id": order_id, "pricing": pricing, ...}
With return_exceptions=True, the gather completes even if some calls fail. You get a mixed list of results and exceptions, and you decide what to do with each one.
That's production-ready. One fetch fails, you don't crash the whole order. You recover with a default. Perfect for the pipeline.
Perfect for the pipeline. And notice: you wrote that logic yourself. You did not copy it from Amir's code. You understood the mechanism and applied it to a real problem.
So to summarize: async def creates a function that can pause. await pauses and waits for the result. asyncio.gather() takes multiple coroutines and runs them all at the same time, waiting for all of them. No threads, no multiprocessing. One thread, one chef, but the kitchen is never idle.
That is the complete picture. Async is concurrency through cooperation, not parallelism through cloning. The chef pauses when there is nothing to do — when something is waiting on I/O — and picks up another task. As soon as I/O completes, the chef resumes. Multiple I/O operations can overlap. CPU time never overlaps. That is why async is perfect for I/O-bound work and terrible for CPU-bound work.
I'm opening a PR for the pipeline fix this week. It's the pattern I just described — gather all the independent fetches and await them together. Multiple similar patterns throughout the pipeline. I've been staring at this code for months. Today it finally makes sense.
Before you do, you need to understand cancellation. What happens when a user disconnects while gather is still running? What happens when a gather in the middle of a long operation times out? These are edge cases that will show up in production. That is your next lesson — asyncio patterns. Gather is the foundation. Error handling, cancellation, and timeouts are how you make it robust.