Learn Python Series (#40) - Asynchronous Python Part 1

Repository
What will I learn?
- You will learn why waiting is different from working and how async exploits that difference;
- the mental model behind cooperative multitasking and the event loop;
- what coroutines actually are and how they differ from normal functions;
- the difference between concurrency and parallelism - often confused concepts;
- when async makes your code faster and when it doesn't help at all.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Python 3(.11+) distribution;
- The ambition to learn Python programming.
Difficulty
- Intermediate, advanced
Curriculum (of the Learn Python Series):
- Learn Python Series - Intro
- Learn Python Series (#2) - Handling Strings Part 1
- Learn Python Series (#3) - Handling Strings Part 2
- Learn Python Series (#4) - Round-Up #1
- Learn Python Series (#5) - Handling Lists Part 1
- Learn Python Series (#6) - Handling Lists Part 2
- Learn Python Series (#7) - Handling Dictionaries
- Learn Python Series (#8) - Handling Tuples
- Learn Python Series (#9) - Using Import
- Learn Python Series (#10) - Matplotlib Part 1
- Learn Python Series (#11) - NumPy Part 1
- Learn Python Series (#12) - Handling Files
- Learn Python Series (#13) - Mini Project - Developing a Web Crawler Part 1
- Learn Python Series (#14) - Mini Project - Developing a Web Crawler Part 2
- Learn Python Series (#15) - Handling JSON
- Learn Python Series (#16) - Mini Project - Developing a Web Crawler Part 3
- Learn Python Series (#17) - Roundup #2 - Combining and analyzing any-to-any multi-currency historical data
- Learn Python Series (#18) - PyMongo Part 1
- Learn Python Series (#19) - PyMongo Part 2
- Learn Python Series (#20) - PyMongo Part 3
- Learn Python Series (#21) - Handling Dates and Time Part 1
- Learn Python Series (#22) - Handling Dates and Time Part 2
- Learn Python Series (#23) - Handling Regular Expressions Part 1
- Learn Python Series (#24) - Handling Regular Expressions Part 2
- Learn Python Series (#25) - Handling Regular Expressions Part 3
- Learn Python Series (#26) - pipenv & Visual Studio Code
- Learn Python Series (#27) - Handling Strings Part 3 (F-Strings)
- Learn Python Series (#28) - Using Pickle and Shelve
- Learn Python Series (#29) - Handling CSV
- Learn Python Series (#30) - Data Science Part 1 - Pandas
- Learn Python Series (#31) - Data Science Part 2 - Pandas
- Learn Python Series (#32) - Data Science Part 3 - Pandas
- Learn Python Series (#33) - Data Science Part 4 - Pandas
- Learn Python Series (#34) - Working with APIs in 2026: What's Changed
- Learn Python Series (#35) - Working with APIs Part 2: Beyond GET Requests
- Learn Python Series (#36) - Type Hints and Modern Python
- Learn Python Series (#37) - Virtual Environments and Dependency Management
- Learn Python Series (#38) - Testing Your Code Part 1
- Learn Python Series (#39) - Testing Your Code Part 2
- Learn Python Series (#40) - Asynchronous Python Part 1 (this post)
GitHub Account
Learn Python Series (#40) - Asynchronous Python Part 1
Your code makes an HTTP request. It takes 200 milliseconds for the server to respond. What does your program do during those 200 milliseconds?
In synchronous code: nothing. It waits. The CPU sits idle. If you need to make 10 requests, you wait 2 full seconds doing absolutely nothing.
In asynchronous code: while waiting for request 1, start requests 2, 3, 4... up to 10. All waiting happens in parallel. Total time: still 200ms, not 2 seconds.
This is what async programming solves.
Nota bene: This episode is about understanding the conceptual model behind async - cooperative multitasking and event loops - not just memorizing async and await syntax.
Waiting vs working: the fundamental distinction
There are two kinds of operations in computing:
CPU-bound: The CPU is actually computing something. Multiplying matrices, compressing data, rendering images, sorting lists. The CPU is busy executing instructions at full speed.
I/O-bound: The CPU is waiting for something external. Network response, disk read, database query, user input. The CPU could do other work but is blocked waiting.
For CPU-bound tasks, async doesn't help. If the CPU is already busy at 100%, there's no spare capacity to do other work. You'd need parallelism (multiple CPU cores) to speed that up.
For I/O-bound tasks, async is transformative. While one operation waits for I/O, the CPU can execute other operations. This is concurrency - doing multiple things in the same time period, even though only one thing runs at any instant.
The mental model: imagine a chef cooking multiple dishes. While the pasta boils (I/O - waiting for time to pass), the chef chops vegetables for another dish (working). While the oven bakes (I/O), the chef plates a finished dish (working). One chef, multiple dishes progressing simultaneously through interleaved work. That's async.
If the chef had to stand and watch the pasta boil without doing anything else, that's synchronous programming.
Concurrency vs parallelism: often confused
Concurrency: Multiple tasks making progress in overlapping time periods. One worker switches between tasks. The chef cooking multiple dishes.
Parallelism: Multiple tasks actually executing at the same instant. Multiple workers, each doing one thing. Multiple chefs, each cooking one dish.
Async is concurrency, not parallelism. You still have one Python interpreter, one thread of execution. But instead of blocking on I/O, it switches to other tasks that can make progress.
For CPU-bound work, you need parallelism (multiprocessing in Python - multiple processes, multiple CPU cores). For I/O-bound work, concurrency is enough and simpler (asyncio - one process, one thread, cooperative multitasking).
The event loop: the conductor
Synchronous code has a simple execution model: start at line 1, execute sequentially until you reach the end. When you call a function, it runs to completion before returning control.
Asynchronous code introduces an event loop - a central coordinator that manages many tasks. Think of it like an operating system's task scheduler, but for your program's tasks instead of system processes.
The event loop maintains a queue of tasks. It runs task A until A says "I'm waiting for I/O". The loop parks task A, starts task B. B does some work, then says "I'm waiting too". Loop parks B, starts task C. Meanwhile, task A's I/O completes - the loop marks it as ready. When the loop cycles back, it resumes task A where it left off.
This is cooperative multitasking. Tasks voluntarily yield control (via await) when they have nothing to do. The loop never forcibly interrupts a task - everything happens by agreement.
The key difference from threads: no preemption, no race conditions, no locks needed. Tasks yield explicitly, so you know exactly when control might switch. This makes async code easier to reason about than threaded code, despite appearing similar.
Coroutines: pauseable functions
A normal Python function runs to completion. Once you call it, it executes straight through to return or the end.
A coroutine (defined with async def) can pause mid-execution and resume later. When a coroutine hits await, it yields control back to the event loop. The coroutine's state is saved - local variables, where it paused, everything. Later, the event loop resumes it exactly where it left off.
The mental model: a coroutine is a function with bookmarks. It can save its place, let other code run, then continue from that bookmark later.
This requires special syntax because normal functions don't support pausing. The async def declaration tells Python "this function can pause". The await keyword marks pause points: "pause here until this operation completes".
When to use async: the decision tree
Use async when:
- You're making many network requests (APIs, web scraping, microservices)
- You're handling many simultaneous connections (web servers, chat servers)
- You're coordinating multiple I/O operations (database + cache + API)
- Waiting is the bottleneck, not computation
Don't use async when:
- You're doing heavy computation (image processing, scientific computing)
- You're calling blocking libraries that don't support async
- Your code is simple and sequential with little I/O
- You'd need to wrap synchronous code in executors (defeats the purpose)
Async adds complexity. The syntax is different, debugging is harder, not all libraries support it. Only use it when the benefit (handling many concurrent I/O operations efficiently) outweighs that complexity cost.
The async/await syntax: expressing pause points
Here's the minimal example:
import asyncio
async def fetch_data():
await asyncio.sleep(1)
return "data"
asyncio.run(fetch_data())
Breaking it down:
async def declares a coroutine function. Calling it doesn't execute the code - it returns a coroutine object that can be awaited.
await pauses the coroutine until the awaited operation completes. You can only await inside async def functions, and you can only await "awaitable" objects (coroutines, tasks, futures).
asyncio.run() starts the event loop and runs the coroutine to completion. This is the entry point from synchronous to asynchronous code.
The constraint: regular functions can't use await. Once you go async, it propagates upward - any function that awaits must itself be async. This is why async is "viral" - it tends to spread through your codebase.
Running multiple tasks concurrently
The power of async emerges when you run multiple coroutines simultaneously:
async def main():
results = await asyncio.gather(
fetch_data("A"),
fetch_data("B"),
fetch_data("C")
)
asyncio.gather() runs all three fetch_data calls concurrently. If each takes 1 second of wait time, total execution is ~1 second, not 3 seconds. The event loop interleaves them.
Contrast with sequential:
async def main():
a = await fetch_data("A") # Wait 1 second
b = await fetch_data("B") # Wait 1 second
c = await fetch_data("C") # Wait 1 second
# Total: 3 seconds
Each await blocks until completion before starting the next. This defeats async's purpose - you're making it synchronous again.
The pattern: create all tasks together, then await them together. This lets the event loop run them concurrently.
Error handling and timeouts
Async operations can fail or run indefinitely. Handle these explicitly:
Exceptions propagate normally through await. If an awaited coroutine raises, the awaiter receives that exception:
try:
result = await risky_operation()
except ValueError:
result = default_value
For timeouts, wrap operations:
try:
result = await asyncio.wait_for(slow_operation(), timeout=5.0)
except asyncio.TimeoutError:
print("Took too long")
This cancels the operation if it doesn't complete in 5 seconds. Without this, a stuck operation could block indefinitely.
Async context managers and iterators
Some resources need async setup/teardown. Database connections that connect asynchronously, for example.
Async context managers use async with:
async with AsyncDatabase() as db:
result = await db.query("SELECT * FROM users")
The __aenter__ and __aexit__ methods can be async, allowing async operations during setup and cleanup.
Similarly, async iterators use async for:
async for item in async_generator():
process(item)
This lets you iterate over data that arrives asynchronously - streaming API responses, database cursors, event streams.
Let's recap
In this episode, we covered the conceptual foundations of asynchronous Python:
- The distinction between I/O-bound (waiting) and CPU-bound (working) operations
- Concurrency (overlapping progress) vs parallelism (simultaneous execution)
- The event loop as coordinator that switches between paused tasks
- Coroutines as functions that can pause and resume their execution
- When async helps (many I/O operations) and when it doesn't (heavy computation)
- The async/await syntax expressing pause points in code
- Running multiple tasks concurrently with gather
- Error handling and timeouts for async operations
Async isn't magic. It's a programming model for efficiently handling many I/O operations by doing useful work during waiting periods instead of blocking.
Thanks for your contribution to the STEMsocial community. Feel free to join us on discord to get to know the rest of us!
Please consider delegating to the @stemsocial account (85% of the curation rewards are returned).
Consider setting @stemsocial as a beneficiary of this post's rewards if you would like to support the community and contribute to its mission of promoting science and education on Hive.