Async Python
Learn how Python handles asynchronous programming using
async, await, coroutines,
tasks, the event loop, and asyncio to
efficiently handle I/O-bound work.
Learning Objectives
Asynchronous programming becomes particularly useful when an application spends a lot of time waiting for external operations such as network requests, databases, files, or other services.
By the end of this lesson, you will be able to:
- Understand synchronous and asynchronous execution.
- Explain what blocking I/O means.
- Understand Python coroutines.
- Use
async def. - Use
awaitcorrectly. - Understand the event loop.
- Use Python's
asynciomodule. - Create and manage asynchronous tasks.
- Run multiple coroutines concurrently.
- Use
asyncio.gather(). - Handle asynchronous exceptions.
- Work with timeouts and task cancellation.
- Understand async generators.
- Recognize when async programming is useful.
- Connect async programming with FastAPI.
What Is Asynchronous Programming?
In normal synchronous programming, operations generally execute one after another.
If one operation has to wait for a slow external resource, the program may remain idle until that operation finishes.
Asynchronous programming provides a way to allow other work to progress while an asynchronous operation is waiting.
Simple idea
Async programming is less about "doing everything at exactly the same time" and more about efficiently managing work that spends time waiting.
This makes asynchronous programming especially valuable for I/O-bound applications.
Synchronous vs Asynchronous
Synchronous execution
def task_one():
print("Task one")
def task_two():
print("Task two")
task_one()
task_two()
The second function starts after the first function completes.
Asynchronous execution
import asyncio
async def task_one():
await asyncio.sleep(2)
print("Task one finished")
async def task_two():
await asyncio.sleep(1)
print("Task two finished")
async def main():
await asyncio.gather(
task_one(),
task_two()
)
asyncio.run(main())
Both coroutines can make progress during their waiting periods.
Important distinction
Async programming provides concurrency, not necessarily parallel execution on multiple CPU cores.
Blocking Operations
A blocking operation prevents the current execution flow from making progress until that operation finishes.
For example, a normal sleep call blocks the current thread:
import time
time.sleep(3)
In an asynchronous program, you normally use:
await asyncio.sleep(3)
Do not confuse them
Calling blocking functions directly inside an asynchronous event loop can prevent other async tasks from progressing efficiently.
Coroutines
A coroutine is an asynchronous function or coroutine object
that can pause at an await point and later
continue execution.
async def greet():
print("Hello")
await asyncio.sleep(1)
print("Goodbye")
Calling an async function does not immediately execute the entire function like a normal function call.
result = greet()
The expression produces a coroutine object that must be awaited or scheduled for execution.
Think of a coroutine as a piece of asynchronous work that can pause and resume.
The async def Keyword
Use async def to define an asynchronous
function.
async def download_data():
print("Downloading data...")
return "Data received"
An asynchronous function can contain one or more
await expressions.
async def process():
data = await download_data()
print(data)
The await expression tells Python that the
coroutine may need to pause while waiting for an
asynchronous operation.
The await Keyword
await is used inside asynchronous code to
wait for an awaitable operation to produce its result.
import asyncio
async def get_data():
await asyncio.sleep(2)
return "Data received"
async def main():
result = await get_data()
print(result)
asyncio.run(main())
Important rule
You cannot normally use await inside an
ordinary synchronous function. It must be used in an
appropriate asynchronous context.
The Event Loop
The event loop is a central part of Python's asynchronous execution model.
It manages asynchronous tasks and resumes them when their awaited operations are ready to continue.
Conceptual flow
- A coroutine starts running.
- It reaches an
await. - The coroutine pauses while waiting.
- The event loop can work on other ready tasks.
- The awaited operation becomes ready.
- The coroutine resumes.
You normally do not need to manually implement an event
loop for everyday asyncio applications. High-level APIs
such as asyncio.run() handle much of the
setup for you.
The asyncio Module
Python's standard library includes the
asyncio module for asynchronous programming.
import asyncio
It provides tools for working with:
- Coroutines
- Tasks
- Event loops
- Timers
- Synchronization primitives
- Concurrent asynchronous operations
One of the most common high-level functions is:
asyncio.run()
asyncio.run()
asyncio.run() is a convenient way to execute
an asynchronous main coroutine.
import asyncio
async def main():
print("Application started")
await asyncio.sleep(1)
print("Application finished")
asyncio.run(main())
The usual pattern is:
async def main():
...
asyncio.run(main())
This is an excellent starting pattern for standalone asyncio programs.
Creating Tasks
A task schedules a coroutine to run within the event loop.
import asyncio
async def work(name, seconds):
await asyncio.sleep(seconds)
print(name, "finished")
async def main():
task1 = asyncio.create_task(
work("Task 1", 2)
)
task2 = asyncio.create_task(
work("Task 2", 1)
)
await task1
await task2
asyncio.run(main())
Once scheduled, the tasks can make progress concurrently when they reach awaitable operations.
Why tasks matter
Tasks are useful when you want to start asynchronous operations and then wait for their results later.
asyncio.gather()
asyncio.gather() is a convenient way to
execute multiple awaitable operations concurrently and
collect their results.
import asyncio
async def get_price(product):
await asyncio.sleep(1)
return f"{product}: price received"
async def main():
results = await asyncio.gather(
get_price("Laptop"),
get_price("Phone"),
get_price("Tablet")
)
for result in results:
print(result)
asyncio.run(main())
Instead of waiting for each operation independently, the three operations can overlap their waiting time.
Practical idea
This pattern can be useful when an application needs information from several independent services.
asyncio.sleep()
asyncio.sleep() suspends the current coroutine
without blocking the event loop in the same way a normal
blocking sleep would.
import asyncio
async def countdown():
print("Starting...")
await asyncio.sleep(2)
print("Two seconds passed")
asyncio.run(countdown())
It is extremely useful for demonstrating asynchronous execution, timers, retry delays, and scheduling concepts.
Concurrency
Concurrency means that multiple tasks can make progress during overlapping periods.
Consider three network operations that each spend two seconds waiting for a response.
Running them strictly one after another could take around six seconds in a simplified example.
If their waiting periods can overlap asynchronously, the overall elapsed time may be closer to the slowest individual operation rather than their total.
The key lesson
Async programming can reduce wasted waiting time for suitable I/O-bound workloads.
But async is not magic
CPU-heavy calculations generally require a different strategy, such as multiprocessing, because asynchronous concurrency does not automatically provide parallel CPU execution.
Async I/O
Asynchronous programming is particularly useful for input/output operations.
Examples of I/O-bound work
- HTTP API requests
- Database queries
- Network communication
- Socket operations
- Waiting for external services
The important principle is that the library performing the operation must support asynchronous usage if you want to await it directly.
async def fetch_data():
response = await some_async_operation()
return response
A normal synchronous library does not automatically
become asynchronous just because you place it inside
an async def function.
Exceptions in Async Code
Asynchronous functions can raise exceptions just like normal functions.
import asyncio
async def risky_operation():
await asyncio.sleep(1)
raise ValueError("Something went wrong")
async def main():
try:
await risky_operation()
except ValueError as error:
print("Error:", error)
asyncio.run(main())
You should handle exceptions at a level where the application can make an appropriate decision.
Timeouts
Network and external operations should not necessarily wait forever.
Python provides asynchronous timeout tools that allow applications to stop waiting after a defined period.
import asyncio
async def slow_operation():
await asyncio.sleep(5)
return "Finished"
async def main():
try:
async with asyncio.timeout(2):
result = await slow_operation()
print(result)
except TimeoutError:
print("Operation timed out")
asyncio.run(main())
Timeouts are important in production systems because external services can become slow or unavailable.
Task Cancellation
An asynchronous task can be cancelled when the application no longer needs its result.
import asyncio
async def long_task():
try:
await asyncio.sleep(10)
print("Task completed")
except asyncio.CancelledError:
print("Task was cancelled")
raise
async def main():
task = asyncio.create_task(
long_task()
)
await asyncio.sleep(1)
task.cancel()
try:
await task
except asyncio.CancelledError:
print("Cancellation confirmed")
asyncio.run(main())
Cancellation is especially useful when requests disappear, timeouts occur, or a larger operation is no longer needed.
Cancellation should be handled carefully so that resources can be cleaned up properly.
Async Generators
Python also supports asynchronous generators using
async def together with yield.
import asyncio
async def numbers():
for number in range(1, 4):
await asyncio.sleep(1)
yield number
async def main():
async for number in numbers():
print(number)
asyncio.run(main())
Notice the use of async for rather than the
normal for loop.
Where this helps
Async generators can be useful for processing or streaming asynchronous data one item at a time instead of loading everything into memory at once.
Async Python with FastAPI
In the previous lesson, you learned that FastAPI supports asynchronous endpoints.
from fastapi import FastAPI
app = FastAPI()
@app.get("/products")
async def products():
return {
"message": "Products loaded"
}
Async endpoints become especially useful when the endpoint needs to await asynchronous I/O.
import asyncio
from fastapi import FastAPI
app = FastAPI()
@app.get("/report")
async def report():
await asyncio.sleep(1)
return {
"status": "Report ready"
}
Connecting the concepts
FastAPI + asynchronous libraries + async/await can provide an efficient architecture for APIs that spend significant time waiting for external resources.
Common Async Python Mistakes
Mistake 1 — Forgetting await
result = get_data()
This may produce a coroutine object instead of the actual result.
When appropriate, use:
result = await get_data()
Mistake 2 — Using time.sleep() in async code
time.sleep(5)
This blocks the current thread and can prevent other tasks from progressing.
For an asynchronous delay, use:
await asyncio.sleep(5)
Mistake 3 — Assuming async means parallel CPU execution
Async concurrency and CPU parallelism are different concepts. CPU-heavy work may require multiprocessing or another specialized strategy.
Mistake 4 — Making every function async
A function should be asynchronous when there is a meaningful reason to use asynchronous behavior. Adding async everywhere can make code unnecessarily complicated.
Mistake 5 — Calling blocking libraries from async code
A synchronous database or network library can still
block execution even when called from an
async def function.
Practice Exercises
Exercise 1 — First Coroutine
Create an async function called
hello() that waits for one second and
then prints a message.
Exercise 2 — asyncio.run()
Create a main() coroutine and run it using
asyncio.run().
Exercise 3 — Multiple Tasks
Create three asynchronous tasks with different sleep durations and observe their completion order.
Exercise 4 — gather()
Use asyncio.gather() to run three
simulated API calls concurrently.
Exercise 5 — Exception Handling
Create a coroutine that raises a ValueError and handle
it using try/except.
Exercise 6 — Timeout
Create an operation that takes five seconds and cancel it using a two-second timeout.
Exercise 7 — Async Generator
Create an async generator that produces five numbers with a short delay between each number.
Mini Challenge — Async Ecommerce Data Collector
Build a small asynchronous program that simulates collecting information from multiple ecommerce services.
Your Tasks
-
Create an async function called
fetch_store_data(). - Give it a store name and delay as parameters.
-
Use
await asyncio.sleep()to simulate network waiting. - Return a dictionary containing the store name and a simulated sales value.
- Create at least four store operations.
-
Run them concurrently with
asyncio.gather(). - Display the results.
- Calculate total sales.
- Add exception handling.
- Add a timeout to the overall operation.
Starter Code
import asyncio
async def fetch_store_data(store, delay, sales):
await asyncio.sleep(delay)
return {
"store": store,
"sales": sales
}
async def main():
results = await asyncio.gather(
fetch_store_data(
"Store A",
2,
1200
),
fetch_store_data(
"Store B",
1,
1800
),
fetch_store_data(
"Store C",
3,
950
),
fetch_store_data(
"Store D",
1.5,
2200
)
)
print(results)
asyncio.run(main())
Challenge goal
Focus on understanding why the operations can overlap
their waiting periods. The goal is not simply to use
async because it is available.
Knowledge Check
Test your understanding of asynchronous Python.
1. What is asynchronous programming mainly useful for?
2. Which keyword defines an asynchronous function?
3. Which keyword waits for an asynchronous operation?
4. What is the event loop responsible for?
5. Which function is commonly used to run an async main coroutine?
6. What does asyncio.gather() help with?
7. Which operation can block an async program if used directly?
8. Does async automatically provide parallel CPU execution?
9. Which construct is useful for consuming an async generator?
10. Why can async programming improve an API server?
Lesson Summary
Asynchronous programming allows Python applications to efficiently manage tasks that spend significant time waiting for external operations.
Key concepts to remember
- Synchronous code generally executes operations sequentially.
- Async programming allows suitable tasks to make progress while other tasks are waiting.
-
async defdefines asynchronous functions. -
awaitwaits for an awaitable operation. - Coroutines represent asynchronous units of work.
- The event loop manages asynchronous execution.
-
asyncio.run()is a common entry point for standalone async programs. -
asyncio.create_task()schedules work as a task. -
asyncio.gather()can run multiple awaitables concurrently. -
asyncio.sleep()provides a non-blocking asynchronous delay. - Async programming is particularly useful for I/O-bound workloads.
- Async does not automatically make CPU-heavy calculations run in parallel.
- Blocking synchronous operations can reduce the benefits of an async application.
- FastAPI can use asynchronous endpoints for suitable workloads.
What's next?
In the next lesson, you will learn about Threading — another approach to concurrency in Python. We will compare threads with asynchronous programming and understand when threads are useful for I/O-bound work.
Ready for the next lesson?
Mark this lesson complete when you can explain
async, await, coroutines,
tasks, the event loop, and asyncio.gather(),
and can identify situations where asynchronous
programming is appropriate.