Mastering Asyncio — Unleashing the Power of Async/Await in Python
Discover the secrets of asynchronous programming and boost your Python projects

In my previous article on Python performance, I intentionally omitted one powerful technique: asynchronous programming. I felt that it deserved its dedicated article, which is the one you’re reading now.
Asynchronous programming is crucial in modern web applications, real-time data processing, and distributed systems. Developers who strive to build scalable and responsive applications need practical methods to manage concurrent tasks without the challenges and performance issues associated with traditional threading and synchronization techniques.
This is where the async/await pattern comes into play, transforming how developers approach asynchronous code.
First, let’s explore a bit of history.
The origins of async/await can be traced back to the C# programming language, introduced by Anders Hejlsberg and his team at Microsoft in C# 5.0 (released in 2012). They recognized developers' challenges when working with asynchronous code, such as the infamous “callback hell,” which made error handling and flow control difficult.
Inspired by earlier work on asynchronous programming patterns, like F#’s asynchronous workflows and C-Omega’s join patterns, they sought to create a solution that made asynchronous code more readable and maintainable, making it look and behave more like synchronous code.
That’s when they came up with the async/await pattern.
The pattern has since been adopted and adapted by many other programming languages, including JavaScript, Python, Dart, Kotlin, and Rust. Each language may have its unique implementation, but the fundamental idea remains: simplify asynchronous programming and improve code readability.
Python added asyncio support in version 3.5. This library makes managing tasks, coroutines, and event loops for concurrent programming easier. It helps developers build faster and more scalable applications more efficiently and with better readability.
This article will explore the inner workings of async I/O, Python’s asyncio library, and practical examples to help you harness the full potential of asynchronous programming in your Python projects.
All the examples featured in this article are available in this dedicated GitHub repository.
What is Asyncio?
In simple terms, asyncio is a programming style for concurrent execution, which allows Python to handle multiple tasks simultaneously. Now asyncio is one of many methods to achieve concurrency in Python.
You can also use the following:
- Multiple processes: This approach leverages the operating system to perform multi-core concurrency. It’s the only option for utilizing multiple cores simultaneously.
- Threads: Here, the operating system handles task switching. However, the Global Interpreter Lock (GIL) prevents multi-core concurrency when using threads in CPython.
However, with asyncio, the operating system doesn’t intervene, and the program runs within a single process and thread.
So how does this work?

Asyncio can be compared to a chess exhibition, where a master player simultaneously takes on multiple opponents.
In a simultaneous chess exhibition, the master player moves from one board to another, making a single move in each game before moving on to the next.
The opponents, in turn, think about their next move while the master is busy with other games. This allows the master player to participate in multiple games concurrently, despite only making one move at a time.
Similarly, asyncio manages multiple tasks within a single thread using an event loop. When a task encounters an I/O-bound operation, such as waiting for data from a server, it gives control back to the event loop.
The event loop then moves on to the next task, allowing it to continue executing while the first task waits for the I/O operation to complete. This process enables the program to work on multiple tasks concurrently, improving overall efficiency and responsiveness.
In both cases, the key to concurrency is efficiently managing waiting time.
In the chess metaphor, the master player utilizes the time opponents spend on thinking, while in asyncio, the event loop leverages the time tasks spend waiting for I/O operations.
By effectively managing these waiting periods, both the master player and asyncio can handle multiple tasks concurrently, despite working on them one at a time.
A basic asyncio example
Let’s dive into asyncio with a practical and engaging example, deviating from most tutorials' typical asyncio—sleep demonstration. In our illustration, we’ll showcase the asynchronous scraping of websites, making the concept both appealing.
In the following example, there are several key points to emphasize. Firstly, we choose httpx
over Python's standard http.client
due to the absence of asyncio support in the standard HTTP
library. It's important to remember that selecting libraries with native support for asynchronous operations is crucial when working with asyncio.
Another point to observe is the addition of the async
keyword before the fetch_content
function, which is essential for enabling the use of the await
keyword.
Lastly, notice the use of async with
instead of the regular with
statement, which is also necessary for incorporating asyncio support.
The most intriguing aspect of our example occurs on the line with resp = await client.get(url)
. Think back to our chess analogy, where the chess master makes a move and immediately proceeds to the next board without waiting for the opponent's response.
Similarly, we initiate an HTTP request but do not wait for the response. While this doesn’t imply that the code moves to the following line instantly, it does pause execution. Meanwhile, the event loop running in the background can proceed with another fetch_content
call following the same pattern.
When the web server returns the response, the paused code resumes execution and continues processing.
The final point to observe is the way we initiate the main function. We use asyncio.run(main())
to launch the primary asynchronous function and manage the underlying event loop, ensuring the smooth execution of our asynchronous code.
import asyncio
import httpx
from bs4 import BeautifulSoup
async def fetch_content(url):
async with httpx.AsyncClient() as client:
resp = await client.get(url)
if resp.status_code == 200:
soup = BeautifulSoup(resp.text, "html.parser")
title = soup.title.string if soup.title else "No title found"
print(f"Title of {url}: {title}")
else:
print(f"Failed to fetch from {url} status: {resp.status_code}")
async def main():
urls = [
"https://www.google.com/",
"https://github.com/",
"https://www.formula1.com/",
]
tasks = [fetch_content(url) for url in urls]
await asyncio.gather(*tasks)
if __name__ == "__main__":
asyncio.run(main())
When we run this example, it shows the following:

Another example using an SQLite database
In the following example, we’ll explore using asyncio while interacting with a database. We’ll employ SQLite as our chosen database for simplicity and easy setup. Although SQLite, a file-based database, might not fully capitalize on the advantages of asyncio, it is a convenient demonstration option.
In a previous example, recall that we opted for httpx instead of the standard http library. Similarly, we cannot use the built-in SQLite library for Python when working with asyncio. Fortunately, the aiosqlite
library is available as an asynchronous alternative. To install it, run pip install aiosqlite
.
The example below demonstrates the use of asyncio with SQLite, focusing on the get_users
function, as most functions follow a similar structure.
Notice the async
keyword prefixing the add_users
function and the use of async with
. Furthermore, observe the await
keyword in the three subsequent lines. This implies that control is returned to the event loop after each line, and the execution pauses until the awaited operation completes.
Another aspect worth noting is inserting multiple users into the database. First, we utilize list comprehension to generate an array of add_user
function calls. Then, we execute these calls concurrently using await asyncio.gather()
. The gather()
function initiates the first task in the array; however, the job pauses when the add_user function encounters an await statement. At this point, gather()
proceeds to launch the subsequent add_user
task and continues this pattern for the remaining tasks.
import asyncio
import aiosqlite
async def create_table(db_name):
async with aiosqlite.connect(db_name) as db:
cursor = await db.cursor()
await cursor.execute(
"CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT);"
)
await db.commit()
async def add_user(db_name, name):
async with aiosqlite.connect(db_name) as db:
cursor = await db.cursor()
await cursor.execute("INSERT INTO users (name) VALUES (?);", (name,))
await db.commit()
async def get_users(db_name):
async with aiosqlite.connect(db_name) as db:
cursor = await db.cursor()
await cursor.execute("SELECT * FROM users;")
users = await cursor.fetchall()
return users
async def main():
db_name = "./db/example.db"
await create_table(db_name)
users = ["Alice", "Bob", "Charlie"]
tasks = [add_user(db_name, user) for user in users]
await asyncio.gather(*tasks)
fetched_users = await get_users(db_name)
print("Users in the database:")
for user in fetched_users:
print(f"ID: {user[0]}, Name: {user[1]}")
if __name__ == "__main__":
asyncio.run(main())
Upon executing the asyncio SQLite example, the following output is displayed:

A chat server and client
Another great example to showcase the power of asyncio is a simple chat server and client application. This example demonstrates how asyncio can efficiently handle multiple connections, allowing concurrent client communication.
The application consists of two parts, the server, and the client. Below you see the server. The handle_client
function accepts reader
and writer
arguments, which are instances of asyncio.StreamReader
and asyncio.StreamWriter
, enabling data transfer with the client's socket. The function continuously processes incoming messages in a loop.
It reads up to 100 bytes from the client, yielding control to the event loop during data reception. If no message is received, the loop ends. Otherwise, the message is displayed on the server console, and an echo message is sent back to the client.
The server is set up in the main function to listen for incoming connections on a specified address and port, using the handle_client
function as a callback. The server runs indefinitely within an async with
block, ensuring proper closure when stopped.
import asyncio
async def handle_client(reader, writer):
addr = writer.get_extra_info("peername")
print(f"New connection from {addr}")
while True:
message = await reader.read(100)
if not message:
print(f"Connection closed with {addr}")
break
print(f"Received message from {addr}: {message.decode()}")
writer.write(f"Echo: {message.decode()}".encode())
await writer.drain()
async def main():
server = await asyncio.start_server(handle_client, "localhost", 12345)
addr = server.sockets[0].getsockname()
print(f"Serving on {addr}")
async with server:
await server.serve_forever()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("Server stopped.")
The chat client example below demonstrates how to use asyncio to create a simple chat client that can concurrently send and receive messages in combination with the server.
The client establishes a connection to the chat server and launches two tasks: one for sending messages (handled by send_message()
) and another for receiving messages (handled by receive_message()
).
Both tasks run concurrently, allowing the client to send and receive messages simultaneously, showcasing the power of asyncio in managing multiple tasks efficiently.
import asyncio
async def send_message(writer):
while True:
message = input("Enter message: ")
writer.write(message.encode())
await writer.drain()
async def receive_message(reader):
while True:
data = await reader.read(100)
if not data:
print("Connection closed.")
break
print(data.decode())
async def main():
reader, writer = await asyncio.open_connection("localhost", 12345)
try:
await asyncio.gather(send_message(writer), receive_message(reader))
finally:
writer.close()
await writer.wait_closed()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("Client stopped.")

Advanced asynchio techniques
In this section, we delve deeper into the asyncio library to explore advanced techniques to help you harness its full potential.
We will discuss and demonstrate how to use Future objects, manage timeouts and task cancellations, handle exceptions, and work with asynchronous context managers and iterators.
By understanding these advanced concepts, you will be better equipped to tackle complex asynchronous programming scenarios and further optimize your Python applications for concurrency and parallelism.
Future objects
Futures represent the result of a computation that may still need to be completed. They are often used to encapsulate the result of an asynchronous operation and can be awaited, making it easy to integrate them into an asyncio workflow.
The example below shows an asynchronous function async_operation()
that simulates a computation by sleeping for 1 second and returning the value 42. In the main()
function, we create an event loop and a Future object called future
. We then define another asynchronous function set_future_result()
, that awaits the result of async_operation()
and sets it as the result of the future
.
The main()
function uses asyncio.gather()
to run both set_future_result()
and future
concurrently. Once the future
object's result is set, the gather()
call will complete, and we can access the result using the future.result()
method. Finally, the result is printed on the console.
import asyncio
async def async_operation():
await asyncio.sleep(1)
return 42
async def main():
loop = asyncio.get_event_loop()
future = loop.create_future()
async def set_future_result():
result = await async_operation()
future.set_result(result)
await asyncio.gather(set_future_result(), future)
print(f"Future result: {future.result()}")
asyncio.run(main())
Timeouts and cancellation
Timeouts and cancellations allow you to manage the execution time of asynchronous tasks, enabling you to stop tasks that take too long to complete or need to be canceled for other reasons.
In the example, we utilize the wait_for
method with a 3-second timeout argument. Since the long_running_task()
function includes a 10-second sleep, the wait_for
method will raise a TimeoutError
exception after 3 seconds, demonstrating how to handle tasks that exceed the specified time limit.
import asyncio
async def long_running_task():
try:
await asyncio.sleep(10)
print("Task completed")
except asyncio.CancelledError:
print("Task canceled")
async def main():
task = asyncio.create_task(long_running_task())
try:
await asyncio.wait_for(task, timeout=3)
except asyncio.TimeoutError:
print("Timeout reached, canceling task")
task.cancel()
await task
asyncio.run(main())
Exception handling
Exception handling in asyncio is similar to regular Python exception handling. You can use try-except blocks to catch exceptions raised within asynchronous functions.
import asyncio
async def fail_after_sleep():
await asyncio.sleep(1)
raise ValueError("Oops! Something went wrong.")
async def main():
try:
await fail_after_sleep()
except ValueError as e:
print(f"Caught exception: {e}")
asyncio.run(main())
Asynchronous context managers and iterators
Asynchronous context managers and iterators enable you to work with asynchronous resources, such as network connections or file I/O, cleanly and efficiently using the async with
and async for
statements.
import aiohttp
import asyncio
async def fetch(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
return await response.text()
async def main():
url = "https://www.formula1.com"
content = await fetch(url)
print(content[:100])
asyncio.run(main())
Common Pitfalls and best practices
In this section, we discuss some common pitfalls to avoid when using asyncio and offer best practices and tips for writing maintainable and efficient asyncio-based code.
Mixing synchronous and asynchronous code
Pitfall: A common pitfall is inadvertently mixing synchronous and asynchronous code, which can block the event loop and reduce performance.
Best Practice: Keep synchronous and asynchronous code separate. If you need to call synchronous functions from within an asynchronous context, consider using run_in_executor()
or converting the synchronous function to an asynchronous one.
Blocking the event loop
Pitfall: Blocking the event loop can cause your program to become unresponsive and slow down.
Best Practice: Avoid using blocking functions within asynchronous code. Instead, utilize non-blocking alternatives, such as asyncio.sleep()
instead of time.sleep()
. If a blocking function is unavoidable, use run_in_executor()
to offload the blocking operation to a separate thread or process.
Ignoring exceptions in tasks
Pitfall: Ignoring exceptions raised within tasks can lead to hidden bugs and issues in your asyncio-based applications.
Best Practice: Always handle exceptions raised within tasks using try-except blocks or by specifying an error callback function with add_done_callback()
.
Using the wrong concurrency primitives
Pitfall: Using the wrong concurrency primitives, like using gather()
when you should use wait()
, can lead to unexpected behavior and reduced performance.
Best Practice: Understand the different concurrency primitives available in asyncio and choose the most appropriate one for your use case. For instance, use gather()
when you need to wait for multiple tasks to complete and use wait()
when you want to wait for at least one task.
Not properly closing resources
Pitfall: Please close resources, such as sockets, files, or database connections, to avoid resource leaks and decreased performance.
Best Practice: Use asynchronous context managers (e.g., async with
) to ensure resources are correctly closed when no longer needed. Alternatively, explicitly close resources in a finally
block.
Forgetting to use ‘await’ with async functions
Pitfall: Forgetting to prefix a call to an async function with ‘await’ can lead to unexpected behavior, as the code will execute but not necessarily provide the expected result.
Best Practice: Always use the ‘await’ keyword when calling async functions to ensure the asynchronous code is executed as intended. The MyPy static type checker contains some rules regarding async/await, such as “unused-awaitable”
Misusing async context managers
Pitfall: Using an async context manager without the proper ‘await’ keyword can cause confusion and unexpected outcomes.
Best Practice: Remember to include the ‘await’ keyword when using async context managers to ensure correct operation and avoid potential issues. The MyPy static type checker contains some rules regarding async/await, such as “unused-awaitable”
Special thanks to Eric PASCUAL for sharing his insights on the last two pitfalls. His experience with async programming has helped enhance our understanding of these particular issues.
With these pitfalls and best practices in mind, you can write more robust, maintainable, and efficient asyncio-based code.
Conclusion
Throughout this article, we’ve explored the powerful world of asynchronous programming in Python, diving into the asyncio library, its history, and its applications in various scenarios.
By understanding and implementing asyncio, you can harness the full potential of async/await patterns in your Python projects, making your applications more efficient, scalable, and responsive.
From basic examples to advanced techniques, we’ve covered the crucial aspects of working with asyncio in Python.
Additionally, we provided you with common pitfalls to avoid and best practices to follow when developing asyncio-based applications.
Now that you have a solid foundation in asyncio, we encourage you to explore and experiment with this powerful library in your projects.
Remember, the key to mastering asyncio is practice, and with time, you’ll be able to unlock the full potential of asynchronous programming in Python.
All the examples in this article are available in this GitHub repository.
Happy coding!