Python asyncio Tutorial: A Beginner’s Guide


Eugenijus Denisov
In This Article
Python’s asyncio is the primary library for enabling asynchronous programming features. In simple terms, synchronous programming executes each line in turn, waiting for the task to complete before executing another line.
Asynchronous programming, on the other hand, can be used to execute a function and continue with other tasks while the former one waits for completion. As such, asynchronous programming is extremely popular in various network functions, numerous applications, file I/O tasks, APIs, and much more.
Learning how to use asyncio properly is part of becoming a good Python developer as you’ll likely use it frequently throughout your career.
What is Python asyncio?
It’s an in-built library with a few key features, namely coroutines, tasks, and event loops. Each of these is used to create asynchronous code, which can be used to execute several functions simultaneously.
Event loop : One of the core features. Everything else is nested in the event loop, which itself ensures that coroutines and tasks can be executed effectively. The event loop performs constant monitoring, scheduling, and dispatching.
Coroutine : A special function that’s defined with async def instead of a regular def. Its unique feature is that it can pause execution with the await keyword.
Task : A wrapper for a coroutine. When a coroutine is scheduled in an event loop, it gets converted to a task for easier management.
Example of Synchronous vs Asynchronous Programming in Python
You can write two formally identical functions in both synchronous and asynchronous programming principles. The only difference between them is how they’ll be executed and the asyncio syntax.
Here’s how synchronous code would look like:
import time
def fetch_data():
print("Fetching data...")
time.sleep(2) # Simulates a blocking I/O operation
print("Data fetched")
def main():
fetch_data()
fetch_data()
if __name__ == "__main__":
main()
In this application, no Python asyncio is used, so the code executes each line after the other. Since it’ll run the main function, it’ll run fetch_data() once and wait until the function completes, then run the second iteration.
On the other hand, here’s how asynchronous code might look like:
import asyncio
async def fetch_data():
print("Fetching data...")
await asyncio.sleep(2) # Non-blocking sleep
print("Data fetched")
async def main():
await asyncio.gather(fetch_data(), fetch_data())
if __name__ == "__main__":
asyncio.run(main())
When the asyncio event loop is run, both iterations of fetch_data() are executed almost simultaneously. If the functions were real data extraction actions, you might get the results almost at the same time instead of one after the other.
In more mathematical terms, synchronous programming will deliver the final results as a sum of all function delays, while asynchronous programming will deliver the final results closer to the longest individual delay.
Setting Up asyncio in Python
Since Python asyncio is a default library, no installation is required. As long as you have Python itself and IDE, you can start using asyncio right off the bat.
Like with any library, to create async functions, you’ll need to import the library first:
import asyncio
That’s all you need to do to create async functions. But to make use of them, however, you’ll need to set up an event loop.
As mentioned previously, you’ll have to define an asynchronous function, which is done by adding the async keyword before the def keyword. Additionally, you can’t just call the function regularly - it has to be wrapped in asyncio for the event loop to perform properly.
import asyncio
async def main():
print("Hello")
await asyncio.sleep(1)
print("World")
asyncio.run(main())
While the function will run an event loop and perform asynchronous tasks, it’ll be hard to notice differences. In fact, since we’re using sleep but have no other asynchronous event loop, the execution may be slower than a synchronous one.
To showcase the power of asynchronous programming, we can use a function with an event loop and several coroutines that return some text after completion. We’ll also run a nearly identical function but with synchronous programming:
import asyncio
import time
# Asynchronous task that simulates an I/O-bound operation.
async def async_task(delay):
await asyncio.sleep(delay)
return f"Task with {delay}s delay completed"
# Run tasks concurrently using asyncio.gather.
async def run_async_tasks():
start = time.perf_counter()
# All tasks start at the same time.
results = await asyncio.gather(
async_task(2),
async_task(2),
async_task(2)
)
end = time.perf_counter()
print(f"Asynchronous tasks completed in {end - start:.2f} seconds")
for result in results:
print(result)
# Synchronous version: tasks run sequentially.
def run_sync_tasks():
start = time.perf_counter()
# Each sleep happens one after another.
time.sleep(2)
print("Task with 2s delay completed")
time.sleep(2)
print("Task with 2s delay completed")
time.sleep(2)
print("Task with 2s delay completed")
end = time.perf_counter()
print(f"Synchronous tasks completed in {end - start:.2f} seconds")
if __name__ == "__main__":
print("Running synchronous tasks:")
run_sync_tasks()
print("\nRunning asynchronous tasks:")
asyncio.run(run_async_tasks())
You can simply copy and paste and run the code. It’ll start by completing the synchronous tasks and print out the completion time. Then, the asynchronous tasks will be run, and the completion time will be output.
In most cases, the synchronous tasks will complete in about 6 seconds, while the asynchronous tasks will complete in about 2 seconds.
Key Functions and Features of Python asyncio
To make the best use of Python’s asyncio library, you’ll need to understand just a few key functions and features. While we have discussed coroutines, asynchronous tasks, and event loops, they have to be implemented properly.
- async def – defines a coroutine
- await – pauses the coroutine until the awaited task is completed.
We’ve used both in our “Hello World” function above. To make use of asynchronous programming, however, we’ll have to look into methods like asyncio.gather() and asyncio.run() .
Gather is a simple method that allows you to run multiple coroutines in your async functions. To run multiple coroutines, all you need to do is add them as arguments in your gather method.
import asyncio
async def fetch_data(delay):
await asyncio.sleep(delay)
return f"Data fetched after {delay} seconds"
async def main():
results = await asyncio.gather(
fetch_data(2),
fetch_data(3),
fetch_data(1)
)
print(results)
asyncio.run(main())
On the other hand, the run method is a higher-level API that creates an event loop and runs coroutines. Notice that the gather method is used to wrap coroutines while run executes the entire main function.
Practical Examples of Python asyncio
If you’re planning to get involved with networking, APIs, apps, or file operations, you’ll likely be using Python asyncio all the time. A great application for the library is sending asynchronous HTTP requests to multiple APIs to reduce overall latency:
import asyncio
import aiohttp # Ensure you install aiohttp with: pip install aiohttp
async def fetch_url(session, url):
async with session.get(url) as response:
return await response.text()
async def main():
urls = [
"https://api.example.com/data1",
"https://api.example.com/data2",
"https://api.example.com/data3",
]
async with aiohttp.ClientSession() as session:
tasks = [fetch_url(session, url) for url in urls]
responses = await asyncio.gather(*tasks)
for response in responses:
print(response[:100]) # Print the first 100 characters of each response
if __name__ == "__main__":
asyncio.run(main())
So, instead of having to wait for each of the calls to complete, you’ll get the results almost simultaneously.
While printing API responses doesn’t seem like a useful application for asynchronous tasks, it’s quite widely used for data retrieval to a single point.
On the other hand, there’s a necessity to sometimes manipulate several files at once. While the default Python asyncio library can’t interact with files properly, there are good third-party ones:
import asyncio
import aiofiles # Install using: pip install aiofiles
async def read_file(filename):
async with aiofiles.open(filename, mode='r') as f:
content = await f.read()
print(f"Content of {filename}:\n{content}\n")
async def main():
files = ["file1.txt", "file2.txt"]
await asyncio.gather(*(read_file(file) for file in files))
if __name__ == "__main__":
asyncio.run(main())
Asynchronous Programming vs Threading vs Multiprocessing
All three can be put under the umbrella term "concurrency". They’re ways to execute code simultaneously, all with unique applications and use cases.
1. Asynchronous programming creates a single CPU thread and uses await to pause a coroutine while waiting for a response, allowing other tasks to be executed. One of the major benefits is that the code is nearly identical to synchronous code.
2. Threading creates multiple threads that run concurrently in the same memory space, managed by the operating system. Great for some I/O applications, however, the Python Global Interpreter Lock (GIL) can make it difficult to implement properly, and sometimes, performance gains may not be realized.
3. Multiprocessing creates separate processes with the goal of bypassing the GIL lock. Perfect for CPU-intensive tasks as it provides true parallelism, but is more resource intensive and creates significant overhead.
Aspect | Asyncio | Threading | Multiprocessing |
---|---|---|---|
Execution Model | Single-threaded event loop; cooperative multitasking | Multiple threads; preemptive multitasking | Multiple processes; true parallelism |
Best For | I/O tasks, network servers, high concurrency, APIs | I/O-bound tasks that require shared state | Heavy computation and CPU-bound tasks |
Overhead | Low overhead (minimal context switching) | Moderate (thread creation and context switching) | High (process creation and IPC overhead) |
Memory | Single memory space | Shared memory space | Separate memory spaces, more isolated |
GIL Impact | Not applicable (single-threaded) | Affected by GIL (limits CPU-bound performance) | Not affected by GIL |
Debugging asyncio Code
Python’s asyncio is a little harder to debug as there are tasks running at once. Sometimes you may get hidden inefficiencies that are hard to spot at first glance. Luckily, there are a few good solutions.
First, is to use the run method with the argument debug=True. Doing so provides detailed logs of execution, which can often help uncover inefficiencies or issues with coroutines and tasks.
There are also additional tools like asyncio.Task.all_tasks() that allow you to inspect running tasks. You could also use third-party debugging tools for additional information.
Finally, it’s recommended to avoid various blocking functions inherited from synchronous programming such as time.sleep(), and use the tools provided by asyncio instead (such as await asyncio.sleep()).

Author
Eugenijus Denisov
Senior Software Engineer
With over a decade of experience under his belt, Eugenijus has worked on a wide range of projects - from LMS (learning management system) to large-scale custom solutions for businesses and the medical sector. Proficient in PHP, Vue.js, Docker, MySQL, and TypeScript, Eugenijus is dedicated to writing high-quality code while fostering a collaborative team environment and optimizing work processes. Outside of work, you’ll find him running marathons and cycling challenging routes to recharge mentally and build self-confidence.
Learn More About Eugenijus Denisov