An Introduction to Asynchronous Programming in Python 3
Running async Python code

Python is considered one of the easiest languages to learn. The Python approach to asynchronous code, on the other hand, can be quite confusing. This article will walk through key concepts and examples of async Python code to make it more approachable.
In particular, you should take away the following:
- Core vocabulary of async programming
- When an asynchronous approach makes sense
- The basics of async code in Python 3
- Useful resources for further investigation
Let’s get started!
What is asynchronous programming?
Asynchronous programs execute operations in parallel without blocking the main process. That’s a mouthful, but all it means is this: async code is a way to make sure your program doesn’t needlessly spend time waiting when it could be doing other work.
If you’ve read anything about async programming before, you’ve probably heard the chess example a dozen times (a master chess player playing a tournament one game at time, versus all the games at once). While this is classically helpful at illustrating the concept, cooking food provides a more relatable metaphor with some spicier details you should keep in mind.
Synchronous Cooking
How frequently have you cooked breakfast like this?
Step 1: Cook eggs (or toast, for our veg friends)
Step 2: Cook bacon (oatmeal)
Step 3: Eat cold eggs/toast and hot bacon/oatmeal
Hopefully, the answer is “literally never.” Cooking one dish at a time before moving onto the next probably makes for some pretty gross food (and it’s totally inefficient). This is what we call: synchronous cooking.
If you have friends that do this regularly, help them immediately.
Asynchronous Cooking
When making a decent-sized meal, it’s rare to want to prepare a single dish at a time. Instead, if you’re making oatmeal and toast, you put the coffee on, then start the water boiling, get out the oatmeal and the bread. When the water is boiling, then you start the oatmeal, and, a few minutes before the oatmeal’s ready, pop the toast in the toaster.
Now, when everything’s ready, you’ve hopefully got hot coffee, toast and oatmeal ready to eat, all about the same time. This is what we call: asynchronous cooking
.
Note that cooking everything at the same time doesn’t reduce the cook time of each dish. You still need to let the toast become golden brown, the coffee percolate, and the oatmeal has to… do whatever oatmeal does when it’s ready. Making toast takes the same amount of time asynchronously as it does synchronously.
However, instead of wasting time waiting on each item to be finished, tasks are performed as the stages of cooking progress. This means multiple tasks are started as soon as possible and your valuable time is used efficiently.
Considerations
Another important feature of the async approach is that the order is less important than when we move on to other tasks. If, for example, we were cooking a 3-course meal, the first course precedes the second which precedes the third. In that scenario, we may need to cook those dishes synchronously.
Even when an async approach is the right one, knowing when to move on to a new task is very important for making it useful. For example, in the boiling water case, we could swap back and forth between turning on the stove, getting out bread, then getting a pot out, et cetera, but, does that really provide us any value?
We’d be jumping around between tasks when there’s not much to wait on. The thing that takes a long time in boiling water is the part where the water needs to heat up on the stove. Asynchronously setting up the water to boil may even be less efficient because we have to move back and forth between tasks (this is called execution overhead, e.g. walking from the stove to the pantry for bread, then from the pantry to the pot storage when pots are closer to the stove).
In short, async isn’t for every use case and isn’t going to magically make your existing synchronous code faster. It’s also not simple to design and requires plenty of forethought about where sequence is more important than efficiency.
With that extended metaphor out of the way, let’s see what async python code actually looks like!
The recipe for async logic in Python 3
You might see a lot of material on the web about the various ways to approach writing async programs in Python (e.g. callbacks, generators, etc — for a full review of that, I recommend this walkthrough), but modern asynchronous code in Python generally uses async
and await
.
A sink and a weight?
async
and await
are keywords in Python 3 used for writing asynchronous programs. The async/await
syntax looks like this:
async def get_stuff_async() -> Dict:
results = await some_long_operation()
return results["key"]
This isn’t much different from the synchronous version:
def get_stuff_sync() -> Dict:
results = some_long_operation()
return results["key"]
The only textual difference is the presence of async
and await
. So, what do async
and await
actually do?
async
just declares that our function is an asynchronous operation.await
tells Python that this operation can be paused until some_long_operation
is completed.
So the functional difference between these two calls is this:
- In
get_stuff_sync
, we callsome_long_operation
, wait on that call to returnresults
, then return the important subset of results. While we wait onresults
, no other operations can be executed because this is a blocking call. - In
get_stuff_async
, we schedulesome_long_operation
, then yield control back to the main thread. Oncesome_long_operation
returnsresults
,get_stuff_async
resumes execution, and returns the important subset ofresults
. While we wait onresults
, the main process is free to execute other operations because this is a non-blocking call.
This is an abstract example, but already you might see some of the benefits (and flaws) of this async approach. The implementation of get_stuff_async
gives us a more efficient approach to using our resources, while get_stuff_sync
provides more certainty about sequencing and simpler implementation.
Making use of async functions and methods is a little more complex than this example, however.
Running async Python code
In the previous example, we saw some important new vocabulary:
- Schedule
- Yield
- Blocking
- Non-blocking
- Main thread
All of these can be explained easier while learning how to run asynchronous code in Python.
In a sync program, we’re able to do this:
if __name__ == "__main__":
results = get_stuff_sync()
print(results) # returns “The Emperor’s Wayfinder is in the Imperial Vault”
And we’d get the results printed to our console.
If you do this with our async code, you get rather different messages:
if __name__ == "__main__":
results = get_stuff_async()
print(results)# returns <coroutine object get_stuff_async at 0x7f80372b9c40>
# bonus!! RuntimeWarning: coroutine 'get_stuff_async' was never awaited
What this tells us is that get_stuff_async
returns a coroutine
object instead of our important results. It also clues us in on why: we never awaited the function itself.
So, we just need to put await
in front of the the function call, right? Unfortunately, it’s not so simple. await
can only be used inside of an async
function or method. Instead of a top-level `await`, we need to schedule our logic on the event loop using asyncio
.
The Event Loop
The core of asynchronous operations in Python is the event loop. Calling it “the” event loop gives it some level of gravitas, right? The truth is that event loops are used in all kinds of programs and they’re not special or magical.
Take any web server: it waits for requests, then when it receives a request, it treats that as an event and matches that event to a response (e.g. going to the URL for this article, the medium backend says “new event: that browser asked for super-genius-article
, we should return super-genius-article.html
"). That is an event loop.
“The” event loop refers to the Python built-in event loop that lets us schedule asynchronous tasks (it also works in multi-threading and subprocesses). All you really need to know about this is that it matches the tasks you schedule to events so that it knows when a process is done.
To use it, we consume the standard library asyncio
module, like so:
import asyncioif __name__ == "__main__": # asyncio.run is like top-level `await`
results = asyncio.run(get_stuff_async())
print(results)# returns “The Emperor’s Wayfinder is in the Imperial Vault”
In most scenarios, this is all you need from the event loop. There are some advanced use cases where you might want to access the event loop directly when writing low-level library or server code, but this is enough for now.
So, our tiny sample script looks like this:
import asyncioasync def some_long_operation():
return {"key": "The Emperor's Wayfinder is in the Imperial Vault"}async def get_stuff_async():
results = await some_long_operation()
return results["key"]if __name__ == "__main__":
results = asyncio.run(get_stuff_async())
print(results)
This definitely works, but there’s nothing about this logic that demands or even benefits from async behavior, so let’s take a look at a more robust example where async is actually beneficial.
Asynchronicity in Network Requests
Sending data to and from different places on the web is a use case for asynchronous programming. Waiting on responses to come back from a slow, remote API is no fun. Executing other important operations while we wait on other data can help improve the efficiency of our program. Let’s write an example of that now.
A Quick, Slow Server
To illustrate this example, we’ll write the slow, remote API ourselves:
import time
import uvicorn
from fastapi import FastAPIapp = FastAPI() # the irony, amirite?
@app.get("/{sleep}")
def slow(sleep: int):
time.sleep(sleep)
return {"time_elapsed": sleep}
if __name__ == "__main__":
uvicorn.run(app) # uvicorn is a server built with uvloop, an asynchronous event loop!
This is a simple server that has one endpoint which takes in the path parameter sleep
, sleeps for that amount of time, and then returns that number in a JSON response. (Want to learn more about writing quality APIs? New article coming soon!)
Needless to say, this will let us simulate some slow operations we might be waiting on.
A Speedy Async Script
Now for the client code, we’ll select some random numbers, and wait some random amounts of time:
import asyncio
import aiohttpfrom datetime import datetime
from random import randrange
async def get_time(session: aiohttp.ClientSession, url: str):
async with session.get(url) as resp: # async context manager!
result = await resp.json()
print(result)
return result["time_elapsed"]
async def main(base_url: str):
session = aiohttp.ClientSession(base_url)
# select 10 random numbers between 0, 10
numbers = [randrange(0, 10) for i in range(10)]
# await responses from each request
await asyncio.gather(*[
get_time(session, url)
for url in [f"/{i}" for i in numbers]
])
await session.close()if __name__ == "__main__":
start_time = datetime.now()
asyncio.run(main("http://localhost:8000"))
print(start_time - datetime.now())
Running this script, we get an output like this:
[7, 2, 6, 4, 0, 9, 4, 2, 5, 5] # times we requested the API to wait
{'time_elapsed': 0} # API response JSON for waiting X seconds
{'time_elapsed': 2}
{'time_elapsed': 2}
{'time_elapsed': 4}
{'time_elapsed': 4}
{'time_elapsed': 5}
{'time_elapsed': 5}
{'time_elapsed': 6}
{'time_elapsed': 7}
{'time_elapsed': 9}
0:00:09.020562 # time it took the program to run
A Not-so-Speedy Sync Script
The synchronous version of the client code, using the same requested times for repeatability, looks like this:
import requests
from datetime import datetime
def get_time(url: str):
resp = requests.get(url)
result = resp.json()
print(result)
return result["time_elapsed"]
def main(base_url: str):
numbers = [7, 2, 6, 4, 0, 9, 4, 2, 5, 5]
print(numbers) for num in numbers:
get_time(base_url + f"/{num}")
if __name__ == "__main__":
start_time = datetime.now()
main("http://localhost:8000")
print(datetime.now() - start_time)
With results:
# returns:[7, 2, 6, 4, 0, 9, 4, 2, 5, 5] # same numbers
{'time_elapsed': 7}
{'time_elapsed': 2}
{'time_elapsed': 6}
{'time_elapsed': 4}
{'time_elapsed': 0}
{'time_elapsed': 9}
{'time_elapsed': 4}
{'time_elapsed': 2}
{'time_elapsed': 5}
{'time_elapsed': 5}
0:00:44.099638 # 5x slower
Observations
Immediately, you might note that the numbers list isn’t sorted, but the output from the remote API is sorted when we run the asynchronous client code. This is because we’re outputting results as we receive them and the longer waits naturally return later than the shorter ones.
Second, we’re making 10 calls in just over 9 seconds when our longest wait time is 9 seconds. The synchronous execution time is the sum of all the times we request from the API, so 44 seconds (or ~5x slower) since it waits for each call to finish before moving on to the next call. The asynchronous execution time is equivalent to the longest wait time we’ve requested (a maximum of 9 seconds in this client code), so significantly more efficient.
Even though the async and sync calls are both requesting the API wait for the same amount of time (44 seconds), we get a much faster overall program by using asynchronous programming.
Lastly, you’ll note these client code samples makes use of some interesting stuff, both from asyncio
and aiohttp
:
- The async context manager (
async with
syntax) is used the same way that a regularwith
statement is used, but in async code - The
aiohttp.ClientSession
object is an API for writing client-side, async network requests — check out more about it in the docs foraiohttp
- The
asyncio.gather
call is a really convenient way to execute a group of asynchronous functions and get their results returned as a list — you can imagine using this to make useful API calls when you need data from multiple places, but don’t want to wait on any single request
Conclusion
Hopefully, this article has provided you with a bit of ammunition to use when you look into using async programming in Python. It’s far from comprehensive, there’s a vast array of knowledge to be pursued as it relates to parallel programming paradigms, but it’s a start.
Key things to remember as you move forward:
- Async isn’t always a slam dunk —in lots of use cases, sync execution will be both simpler and faster than async programming because not every program has to sit around waiting for data to come back to it
- Using async requires design-thinking about the sequencing of your program and when you need what pieces of data, whereas synchronous code tends to take for granted that the data is there, returned immediately by every call
- While there are a number of ways to write async code, you almost always want
async/await
— if you aren’t certain you need something else, you wantasync/await
syntax
That’s all for now! Good night and good luck on your asynchronous programming journey.
Resources
This isn’t the sum of all the things that helped make this article, but they’re great things to look at regardless.
- asyncio: https://docs.python.org/3/library/asyncio.html
- uvloop: https://uvloop.readthedocs.io/
- FastAPI: https://fastapi.tiangolo.com/
- Awesome Asyncio: https://github.com/timofurrer/awesome-asyncio