Synchronous vs Asynchronous Programming in Python

Understand the difference between synchronous and asynchronous programming in Python, how the event loop works, and when async code is actually faster.

7 min read

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:

pythonpython
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:

texttext
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.00s

Each 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:

pythonpython
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:

texttext
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.00s

All 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.

pythonpython
# 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.

pythonpython
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 result

Second, 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. Task is a subclass of Future. Most application code works with coroutines and tasks directly.

pythonpython
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.
pythonpython
# 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.
pythonpython
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

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.
RunePowered by Rune AI

Frequently Asked Questions

Does async make my Python code run faster?

Async does not make individual operations faster. An HTTP request that takes 200 ms in synchronous code still takes 200 ms in async code. What async does is let you run many such requests concurrently in a single thread, so the total wall-clock time is the duration of the longest single operation rather than the sum of all operations. If your program makes one request and waits for it, async gives no benefit. If it makes 100 requests, async can reduce total time from 100 times the request duration to roughly the duration of one request plus scheduling overhead.

Can I call async functions from synchronous Python code?

Not directly. You need an event loop to run async functions. From synchronous code, you can use asyncio.run() to start the event loop and run a single async function to completion. You cannot await an async function inside a synchronous function. From within an already-running event loop, you can run synchronous code in a thread pool using loop.run_in_executor(). Mixing sync and async code requires deliberate boundary management.

Why can't I use requests.get() inside an async function?

requests.get() is a synchronous, blocking function. When called from inside an async function, it blocks the entire event loop, preventing all other async tasks from making progress. Use an async HTTP client like aiohttp or httpx with async support instead. If you must use a synchronous library from async code, wrap it in loop.run_in_executor() to run it in a thread pool without blocking the event loop.

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.