Every Python program you have written so far has probably been synchronous. You call a function, it runs to completion, and the next line executes only after the function returns. When the function performs I/O, like reading a file or making an HTTP request, your program waits.
The CPU sits idle while the bytes travel over the network or spin off the disk. Asynchronous programming changes this. With async Python, when a function would wait for I/O, it yields control to a scheduler called the event loop, which switches to another task that is ready to make progress.
The I/O still takes the same amount of clock time, but your program can do useful work during the wait instead of blocking. If you have compared threads, processes, and async, you know async is one of Python's three concurrency models. This article focuses on the conceptual shift: what changes when you move from sync to async thinking.
The shift matters because async code looks similar to sync code at the syntax level but behaves completely differently at runtime. A function defined with async def does not run when you call it. It returns a coroutine object that must be awaited or scheduled.
A call to await does not block the thread like a synchronous call would; it suspends the current coroutine and lets the event loop run other coroutines. These differences are easy to miss when reading code and are the source of most async bugs in Python.
What synchronous code does during I/O
Consider a synchronous program that fetches three URLs:
import time
def fetch(url):
print(f"Fetching {url}...")
time.sleep(1)
print(f"Done fetching {url}")
return f"Content of {url}"
start = time.perf_counter()
fetch("https://a.example.com")
fetch("https://b.example.com")
fetch("https://c.example.com")
print(f"Total: {time.perf_counter() - start:.2f}s")Each call to fetch() fully completes, including its one-second sleep, before the next call even starts, which is why the three fetches add up to three full seconds instead of overlapping:
Fetching https://a.example.com...
Done fetching https://a.example.com
Fetching https://b.example.com...
Done fetching https://b.example.com
Fetching https://c.example.com...
Done fetching https://c.example.com
Total: 3.00sEach fetch runs to completion before the next begins. The total time is the sum of each operation. During each time.sleep(), the program does nothing.
The thread is blocked. This is the synchronous model: one thing at a time, in strict order, with waiting periods where the CPU is idle.
For a script that fetches three URLs, 3 seconds is fine. For a server that handles thousands of requests per second, each spending 200 ms waiting for a database, blocking during every wait means the server can handle at most 5 requests per second per thread. Threads can help, but each thread consumes memory, and at high concurrency the overhead dominates.
What asynchronous code does during I/O
Now consider the same program written with asyncio:
import asyncio
import time
async def fetch(url):
print(f"Fetching {url}...")
await asyncio.sleep(1)
print(f"Done fetching {url}")
return f"Content of {url}"
async def main():
start = time.perf_counter()
results = await asyncio.gather(
fetch("https://a.example.com"),
fetch("https://b.example.com"),
fetch("https://c.example.com"),
)
print(f"Total: {time.perf_counter() - start:.2f}s")
return results
asyncio.run(main())All three fetch() calls print their starting message before any of them finishes, because asyncio.gather() starts them concurrently and each one yields control at await asyncio.sleep(1) instead of blocking:
Fetching https://a.example.com...
Fetching https://b.example.com...
Fetching https://c.example.com...
Done fetching https://a.example.com
Done fetching https://b.example.com
Done fetching https://c.example.com
Total: 1.00sAll three fetches start at nearly the same time. The total is 1 second, not 3. The key is await asyncio.sleep(1).
When execution reaches that line, the coroutine tells the event loop, "I am about to wait for 1 second. Wake me up when the time is up, and run something else in the meantime." The event loop switches to the next coroutine, which does the same. All three coroutines are suspended during their respective waits, and the event loop handles waking each one when its timer fires.
This cooperative multitasking is the essence of async programming. Coroutines voluntarily yield control at await points. The event loop is a single-threaded scheduler that tracks which coroutines are waiting for which events and runs whichever one is ready.
The event loop: the invisible scheduler
The event loop is not magic. It is a loop that repeatedly checks: are any registered I/O events ready? Are any timers expired?
If so, resume the coroutine that was waiting for that event. If not, wait (efficiently, using OS-level I/O multiplexing like epoll or kqueue) until something is ready.
# Conceptual event loop (not real code, simplified for illustration)
while running:
ready_events = check_for_completed_io()
for event in ready_events:
resume_coroutine(event.callback)
expired_timers = check_timers()
for timer in expired_timers:
resume_coroutine(timer.callback)The real event loop in asyncio is more complex, handling signal handlers, subprocess watchers, and thread pool integration, but the core idea is the same. The loop does not use CPU cycles polling. It uses efficient OS primitives to sleep until something interesting happens, then wakes and dispatches the right callback.
asyncio.run() creates a new event loop, runs a coroutine until it completes, and then closes the loop. This is the standard entry point for async programs. Inside the loop, you create tasks, await coroutines, and schedule work.
What await actually does
await is the only point in an async function where execution can be suspended. Between await statements, the coroutine runs uninterrupted, just like synchronous code. This has two important consequences.
First, there are no arbitrary context switches in async code. Unlike threads, where the OS can preempt you between any two bytecode instructions, async code only yields at await. If you have a CPU-heavy computation between two await calls, no other coroutine can run during that computation.
This is why async is for I/O-bound work, not CPU-bound work.
async def bad_example():
# This blocks the event loop for the entire computation
result = sum(i * i for i in range(10_000_000))
await asyncio.sleep(0) # Other coroutines only get a chance here
return resultSecond, await must be inside an async def function. You cannot use await at the top level of a module or inside a regular def function. This means async code tends to propagate: once one function is async, the functions that call it typically need to be async too, up to the entry point where asyncio.run() is called.
Coroutines, tasks, and futures
Three types of objects appear in async Python, and understanding the differences helps you read error messages and tracebacks:
-
Coroutine: The object returned by calling an async def function. It does nothing until awaited or wrapped in a task. A coroutine is a suspended computation waiting to be scheduled.
-
Task: A coroutine wrapped by
asyncio.create_task()and submitted to the event loop. A task runs concurrently with other tasks. The event loop manages its lifecycle. -
Future: A low-level awaitable that represents a result that will be available at some point.
Taskis a subclass ofFuture. Most application code works with coroutines and tasks directly.
import asyncio
async def greet(name):
await asyncio.sleep(0.1)
return f"Hello, {name}"
async def main():
# Creates a coroutine object. Nothing runs yet.
coro = greet("World")
print(type(coro))
# Wraps it in a Task and schedules it on the event loop.
task = asyncio.create_task(greet("World"))
print(type(task))
# Await the task to get its result.
result = await task
print(result)
asyncio.run(main())Creating a task with create_task() schedules the coroutine to run concurrently. If you await a coroutine directly instead of wrapping it in a task, it runs but does not yield control until it hits its own await. Multiple coroutines awaited sequentially run sequentially, not concurrently.
When sync is the right choice
Async is not always better. Synchronous code is simpler to write, easier to debug, and works with every Python library. Use synchronous code when:
- Your program does one thing at a time and has no concurrency needs.
- Your bottleneck is CPU, not I/O. Async cannot speed up computation.
- Your dependencies are synchronous and converting them is impractical.
- You are writing a script that runs once and exits. The async boilerplate is not worth it for a 3-second script.
# This is fine as synchronous code
def process_file(filepath):
with open(filepath) as f:
return f.read().upper()
result = process_file("data.txt")
print(len(result))Adding async def and await to this function would make it more complex with no benefit. The file read blocks, but there is nothing else to do while waiting.
When async wins
Async becomes valuable when your program spends significant time waiting for multiple independent I/O operations. The canonical use cases are:
- Web servers handling many concurrent client connections.
- Web scrapers fetching hundreds of pages.
- API clients making many parallel calls to downstream services.
- Real-time applications like chat servers and WebSocket handlers.
- Database applications issuing many concurrent queries.
import asyncio
import aiohttp
async def fetch_url(session, url):
async with session.get(url) as response:
return await response.text()
async def fetch_all(urls):
async with aiohttp.ClientSession() as session:
tasks = [fetch_url(session, url) for url in urls]
return await asyncio.gather(*tasks)With 100 URLs, this async version finishes in roughly the time of the slowest response. A synchronous version would take the sum of all response times. At scale, the difference is the difference between a usable service and one that times out.
The async library ecosystem requirement
The most significant practical constraint on async Python is that you need async-compatible libraries. Every I/O operation inside an async function must use an async-aware library, or you must wrap synchronous calls in run_in_executor(). The standard library modules requests, sqlite3, time, and subprocess (without async support) will all block the event loop if called directly from async code.
The async ecosystem is mature as of 2026. HTTP clients like aiohttp and httpx (with async support) replace requests. Database drivers like asyncpg (PostgreSQL), aiomysql, and aiosqlite replace synchronous DB-API drivers.
File I/O can use aiofiles. The next article covers the async and await syntax in detail, showing you how to write async functions, create tasks, and handle errors in async code.
Rune AI
Key Insights
- Synchronous code executes one operation at a time and blocks during I/O; asynchronous code yields control during I/O so other tasks can run.
- Async does not speed up individual operations; it reduces total time by overlapping I/O waits across many tasks.
- The event loop is a single-threaded scheduler that manages async tasks cooperatively.
- Async requires async-compatible libraries; a single blocking synchronous call stalls the entire event loop.
- Use async when your program makes many concurrent I/O operations; use sync for CPU-bound work or simple scripts with few I/O calls.
Frequently Asked Questions
Does async make my Python code run faster?
Can I call async functions from synchronous Python code?
Why can't I use requests.get() inside an async function?
Conclusion
The difference between synchronous and asynchronous programming is not about speed per operation. It is about what your program does while waiting. Synchronous code blocks the entire thread during I/O. Asynchronous code yields control so other tasks can use that waiting time. The async and await syntax in Python makes this cooperative multitasking explicit and readable, but it requires an event loop, async-compatible libraries, and a different mental model for control flow. The next article covers the async and await syntax in detail with practical examples you can run.
More in this topic
How to Test Python Code
Learn what testing means in Python, the core ideas behind automated tests, and the built-in and third-party tools that make testing practical.
Write Your First Python Unit Test
Write and run your first Python unit test step by step, using unittest to check a small function and understand the test output.
The Factory Pattern in Python
Learn how to use the factory pattern in Python to encapsulate object creation, making your code more flexible and easier to extend.