Python Asyncio Explained Simply: async, await, and Why Your Code Is Still Slow
Understand async, await, and the event loop with easy examples.
Imagine your application needs data from two APIs.
API A takes 3 seconds. API B takes 2 seconds.
Normally, Python calls API A, waits for its response, and then calls API B.
# Normal Execution
API A [========== 3s ==========]
API B [===== 2s =====]
# Total: 5 secondsBut here’s the interesting part.
Both APIs are independent. So why should API B wait for API A to finish?
What if Python could start both requests without waiting for one to complete?
# With Asyncio
API A [========== 3s ==========]
API B [===== 2s =====]
# Total: ~3 secondsSame APIs. Same response times. But approximately 40% less total waiting (from 5s to 3s).
We didn’t make the APIs faster. We simply allowed their waiting periods to overlap.
That’s the idea behind Python’s asyncio.
But how does Python manage multiple operations without running them all at the exact same time?
The answer lies in four concepts: async, await, coroutines, and the event loop.
Let’s understand how they work together.
1. Synchronous vs. Asynchronous Programming
In synchronous programming, Python executes operations one after another.
import time
def fetch_api_a():
time.sleep(3)
return "API A Data"
def fetch_api_b():
time.sleep(2)
return "API B Data"
result_a = fetch_api_a()
result_b = fetch_api_b()Here, time.sleep() simulates API response time.
Python waits 3 seconds for API A, then another 2 seconds for API B.
Total: 5 seconds.
Asynchronous programming allows Python to work on other scheduled tasks while one operation is waiting.
The key idea:
Asyncio doesn’t make an API faster. It helps us use waiting time better.
2. async def and Coroutines
A normal Python function executes when we call it.
def greet():
print("Hello")
greet()
# output : HelloBut an asynchronous function behaves differently.
async def greet():
print("Hello")
result = greet()
print(result)Instead of printing "Hello", Python returns something like:
<coroutine object greet at 0x...>Why?
Because async def just defines a coroutine function.
coroutine function : A special function that can pause its execution, save its state, and resume later from the exact same point.
Calling it creates a coroutine object.
But doesn’t immediately execute the function body.
To execute it, we can use:
import asyncio
asyncio.run(greet())Now "Hello" is printed.
Remember:
async defdefines a coroutine function.
2. Calling that function creates a coroutine object.
3. Awaiting or scheduling the coroutine allows it to execute.
Note:
- An
async deffunction doesn't always have to containawait- simply defining a function with
async defdoes not automatically make its execution asynchronous or non-blocking.how we schedule it is important (see Section 5)
3. What await Actually Does
Consider these two lines:
time.sleep(3)
await asyncio.sleep(3)Both introduce a delay of approximately 3 seconds, but they behave differently.
time.sleep(3) blocks the current thread.
await asyncio.sleep(3) suspends the current coroutine, allowing the event loop to execute other ready tasks.
For example:
async def fetch_api_a():
print("API A started")
await asyncio.sleep(3)
print("API A completed")When Python reaches await, the coroutine can pause until the operation is ready.
Meanwhile, other scheduled tasks can run.
Once the wait finishes, the coroutine resumes.
await works with awaitable objects, including coroutines, Tasks, and Futures.
Important:
awaitdoesn't automatically make multiple operations concurrent.It simply waits for an awaitable’s result.
4. The Event Loop: Who Manages Everything?
If a coroutine pauses, who resumes it?
That’s the job of the event loop.
Think of the event loop as a coordinator that manages multiple asynchronous tasks.
It runs on a single thread, switching between tasks whenever one is waiting for an operation to complete.
For example:
Event Loop
|
|-- Start API A
| |
| |-- Waiting for response
|
|-- Start API B
| |
| |-- Waiting for response
|
|-- API B response ready -> Resume B
|
|-- API A response ready -> Resume AThe event loop runs ready tasks and coordinates suspended tasks until their awaited operations are ready.
It doesn’t automatically execute every coroutine we define. Coroutines must be awaited or scheduled.
Where does asyncio.run() fit?
asyncio.run(main())This is a common entry point for standalone asynchronous Python programs.
5. The Biggest Misunderstanding: Async Does Not Mean Concurrent
Let’s return to our two APIs.
import asyncio
async def fetch_api_a():
await asyncio.sleep(3)
return "API A Data"
async def fetch_api_b():
await asyncio.sleep(2)
return "API B Data"
async def main():
result_a = await fetch_api_a()
result_b = await fetch_api_b()
asyncio.run(main())How long will this take?
Approximately 5 seconds!
Surprising?
Both functions are asynchronous, but we’re awaiting them sequentially.
- API A takes 3 seconds.
- API B takes 2 seconds.
- Python starts API B only after API A finishes.
But wait…
Didn’t we say that await suspends the current coroutine and allows the event loop to run other tasks?
Then why doesn’t Python start API B while API A is waiting?
Get Deep Pathak’s stories in your inbox
Join Medium for free to get updates from this writer.
This is where many beginners get confused.
The missing piece: Where does the other task come from?
Look at this line:
result_a = await fetch_api_a()When Python executes this line:
- It starts executing
fetch_api_a(). - It reaches
await asyncio.sleep(3). - The current coroutine suspends.
- Control returns to the event loop.
- The event loop checks for other scheduled tasks.
But here’s the important part:
API B has not been scheduled yet!
It exists as a coroutine function, but Python hasn’t been told to run it.
The event loop cannot automatically start every other async function in your code.
It can only run other tasks that have already been scheduled and are ready to execute.
After API A finishes, Python moves to the next line:
result_b = await fetch_api_b()Now API B starts.
That’s why the total execution time is:
API A: [========== 3s ==========]
API B: [===== 2s =====]
Total: 5 secondsSo how do we make API B available while API A is waiting?
We need to schedule both operations before waiting for their results.
For example:
async def main():
task_a = asyncio.create_task(fetch_api_a())
task_b = asyncio.create_task(fetch_api_b())
result_a = await task_a
result_b = await task_bNow both tasks are scheduled.
Here’s what happens:
- Python schedules API A.
- Python schedules API B.
- API A starts and reaches its 3-second sleep.
- API A suspends.
- The event loop can now run API B.
- API B reaches its 2-second sleep and suspends.
- Both tasks resume when their respective waits finish.
Even though we await the results one after another, both tasks have already been scheduled.
6. asyncio.gather(): Making Both APIs Run Concurrently
Let’s change our main() function:
async def main():
result_a, result_b = await asyncio.gather(
fetch_api_a(),
fetch_api_b()
)
print(result_a, result_b)
asyncio.run(main())Now both API operations can make progress concurrently.
The execution looks like this:
Seconds: 0 1 2 3
|----|----|----|
API A [==============]
API B [=========]
Total time: ~3 secondsAPI A takes 3 seconds.
API B takes 2 seconds.
But because their waiting periods overlap, the total is approximately 3 seconds instead of 5.
asyncio.gather()schedules the supplied coroutines, waits for their results when they complete successfully, and returns those results in the order the awaitables were provided, even if they finish in a different order.
7. asyncio.create_task() vs. asyncio.gather()
We can achieve the same concurrency using asyncio.create_task().
async def main():
task_a = asyncio.create_task(fetch_api_a()) # <-- schedules API A to run concurrently.
task_b = asyncio.create_task(fetch_api_b()) # <-- schedules API B to run concurrently.
result_a = await task_a # <-- waits for API A to complete.
result_b = await task_b # <-- waits for API B to complete.
asyncio.run(main()) # <-- starts and manages the event loop.Why does this take approximately 3 seconds instead of 5 seconds?
Because both tasks were scheduled before we started awaiting their results.
A Task manages a coroutine’s execution on the event loop.
The main difference:
asyncio.gather(): Runs multiple coroutines together and collects their results.asyncio.create_task(): Schedules a coroutine as a Task, we can manage and await it separately.
(when you need more control over individual tasks)
8. Concurrency vs. Parallelism
These terms sound similar but mean different things.
Concurrency: Multiple tasks make progress during overlapping periods. (asyncio — single thread)
Parallelism: Multiple tasks execute at the exact same time.
(multiple threads or processes)
For example, asyncio can handle two API requests concurrently using a single event-loop thread.
While API A waits, Python can work on API B.
But this doesn’t mean two Python coroutine bodies are executing simultaneously on separate CPU cores.
Asyncio is mainly useful for I/O-bound operations, such as API requests and database calls.
For CPU-heavy calculations, multiprocessing or other parallel approaches may be better.
Also Remember :
Multithreading: Multiple threads execute tasks concurrently, with the operating system managing thread scheduling.
Asyncio: A single thread typically handles multiple tasks by switching between them whenever a task is waiting (
await).
9. Asyncio vs. Celery: When to Use Which?
Let’s understand this through an HR application.
An employee submits a leave request.
The application needs to check the employee’s leave balance, fetch manager details, send an email, and generate a confirmation PDF.
Where asyncio helps
Suppose leave balance and manager details come from two independent APIs.
balance, manager = await asyncio.gather(
fetch_leave_balance(),
fetch_manager_details()
)Asyncio helps fetch both concurrently, reducing unnecessary waiting.
Where Celery helps
After the leave request is saved, sending an email and generating a PDF may not need to happen before responding to the employee.
Celery can handle those jobs through background workers.
send_leave_email.delay(employee_id)Celery commonly uses a message broker such as Redis or RabbitMQ to send jobs to separate worker processes or machines.
They can also work together.
For example, use asyncio to fetch data efficiently during a request, then use Celery to send notifications afterward.
Remember:
Asyncio : helps us use waiting time efficiently.
Celery : helps us move work into background workers.
Final Thoughts
The biggest lesson about asyncio is not the syntax.
It’s understanding what happens while Python is waiting.
In synchronous code, one blocking operation can stop the current thread from doing anything else.
With properly written asynchronous code, a coroutine can suspend while waiting, and the event loop can run other scheduled tasks.
But remember: simply using async and await doesn't guarantee concurrency.
We need to schedule independent operations so they can overlap.
And that’s the real power of asyncio.
Asyncio doesn’t make slow operations faster. It helps your application stop wasting time while waiting for them.






