Last updated: 2026-05-22

Asynchronous python

Asynchronous programming allows a Python program to handle multiple tasks concurrently without blocking — starting a task, moving on to other work while waiting for it to complete, and coming back to handle the result when it is ready. This is particularly valuable for I/O-bound work: making multiple network requests, reading multiple files, or querying multiple databases simultaneously rather than waiting for each to finish before starting the next.

Python's primary tool for asynchronous programming is the asyncio library, introduced in Python 3.4 and significantly improved through subsequent versions. The async/await syntax, added in Python 3.5, makes asynchronous code readable and approachable.


The Problem: Blocking I/O

In synchronous code, each operation waits for the previous one to complete. When the operation involves waiting — for a network response, for a file to load, for a database query to return — the program simply sits idle:

import time
import requests

def fetch(url):
    response = requests.get(url)    # Blocks here until the response arrives
    return response.status_code

start = time.time()
fetch("https://httpbin.org/delay/1")   # Wait 1 second
fetch("https://httpbin.org/delay/1")   # Wait another second
fetch("https://httpbin.org/delay/1")   # And another
print(f"Total: {time.time() - start:.1f}s")   # ~3 seconds

Three requests that each take 1 second run sequentially — total time 3 seconds. If they could run concurrently, the total would be closer to 1 second.


Concurrency vs. Parallelism

Before going further, an important distinction:

ConcurrencyParallelism
DefinitionMultiple tasks make progress by taking turnsMultiple tasks execute at exactly the same time
HowSingle thread, cooperative multitaskingMultiple CPU cores/threads
Best forI/O-bound work (waiting on network, files)CPU-bound work (calculations, data processing)
Python toolsasyncio, async/awaitmultiprocessing, concurrent.futures

asyncio is a concurrency tool — it does not use multiple threads or CPU cores. It runs on a single thread, switching between tasks when one is waiting for I/O. For CPU-bound parallelism (heavy computation), use multiprocessing.


Core Concepts

Coroutines

A coroutine is a function defined with async def. It can be paused and resumed, allowing other coroutines to run while it waits:

import asyncio

async def greet(name):
    await asyncio.sleep(1)    # Pause here — let other coroutines run
    print(f"Hello, {name}!")

# A coroutine must be awaited or run by the event loop
asyncio.run(greet("Wariz"))   # Hello, Wariz! (after 1 second)

Calling greet("Wariz") does not execute the function — it returns a coroutine object. The function only runs when it is awaited or passed to the event loop.

The Event Loop

The event loop is the engine of asyncio. It manages and schedules coroutines, keeping track of which ones are waiting for I/O and which are ready to run. When a coroutine hits an await, it hands control back to the event loop, which can run another coroutine while waiting.

asyncio.run() creates an event loop, runs a coroutine to completion, and closes the loop:

asyncio.run(main())    # Entry point for async programs

await

await pauses the current coroutine until the awaited coroutine (or other awaitable) completes, and returns its result. It can only be used inside an async def function:

async def fetch_data():
    data = await get_from_database()    # Pause until DB responds
    return data

Running Coroutines Concurrently — asyncio.gather()

The real benefit of asyncio appears when running multiple coroutines concurrently. asyncio.gather() runs multiple coroutines and waits for all of them to complete:

import asyncio

async def fetch(url, delay):
    await asyncio.sleep(delay)    # Simulates a network request
    return f"Response from {url}"

async def main():
    results = await asyncio.gather(
        fetch("https://api-one.com", 1),
        fetch("https://api-two.com", 1),
        fetch("https://api-three.com", 1),
    )
    for result in results:
        print(result)

import time
start = time.time()
asyncio.run(main())
print(f"Total: {time.time() - start:.1f}s")   # ~1 second, not ~3

All three coroutines run concurrently — each waits for 1 second, but they wait at the same time. Total elapsed time is approximately 1 second.


asyncio.create_task()

asyncio.gather() runs coroutines and waits for all results. asyncio.create_task() schedules a coroutine to run soon and returns a Task object immediately — without waiting for it. This is useful when you want to fire off work and check on it later:

async def main():
    task1 = asyncio.create_task(fetch("https://api-one.com", 1))
    task2 = asyncio.create_task(fetch("https://api-two.com", 2))

    # Do other work here while tasks run in the background
    print("Tasks started, doing other work...")

    result1 = await task1    # Wait for task1
    result2 = await task2    # Wait for task2

    print(result1)
    print(result2)

asyncio.run(main())

Handling Exceptions in Async Code

Exceptions in coroutines propagate normally through await:

async def risky():
    await asyncio.sleep(0.5)
    raise ValueError("Something went wrong")

async def main():
    try:
        await risky()
    except ValueError as e:
        print(f"Caught: {e}")

asyncio.run(main())

Exceptions in gather()

By default, if any coroutine in gather() raises an exception, gather() propagates it immediately (other coroutines are cancelled). To get all results even when some fail, use return_exceptions=True:

results = await asyncio.gather(
    fetch("https://good-api.com", 1),
    fetch("https://bad-api.com", 1),
    return_exceptions=True
)

for result in results:
    if isinstance(result, Exception):
        print(f"Error: {result}")
    else:
        print(result)

Timeouts — asyncio.wait_for()

To set a maximum wait time for a coroutine:

async def main():
    try:
        result = await asyncio.wait_for(
            fetch("https://slow-api.com", 5),
            timeout=2.0    # Cancel after 2 seconds
        )
    except asyncio.TimeoutError:
        print("Request timed out")

asyncio.run(main())

Async Context Managers

Resources that require setup and teardown in an async context — like async database connections or HTTP sessions — are managed with async with:

import aiohttp    # Third-party async HTTP library

async def fetch_url(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            return await response.text()

An async context manager implements __aenter__ and __aexit__ instead of __enter__ and __exit__.


Async Iterators and async for

For iterating over asynchronous sequences — data coming in over a network connection, paginated API responses, database result streams:

async def async_range(n):
    for i in range(n):
        await asyncio.sleep(0.1)    # Simulate async data arrival
        yield i

async def main():
    async for value in async_range(5):
        print(value)    # 0, 1, 2, 3, 4

asyncio.run(main())

async for works with objects that implement __aiter__ and __anext__.


asyncio vs. Threading vs. Multiprocessing

Python offers three approaches to concurrency and parallelism. Choosing the right one depends on what your code is waiting on:

ApproachModuleBest forNotes
asyncioasyncioI/O-bound work — many network requests, file operationsSingle thread, cooperative; cannot use multiple CPU cores
ThreadingthreadingI/O-bound work with blocking librariesMultiple threads, but the GIL limits true parallelism
MultiprocessingmultiprocessingCPU-bound work — heavy computationTrue parallelism across CPU cores; higher overhead

concurrent.futures — a unified interface

concurrent.futures provides a high-level interface that works with both threads and processes:

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import requests

urls = [
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
]

def fetch(url):
    return requests.get(url).status_code

# Thread pool — good for I/O-bound work with blocking libraries
with ThreadPoolExecutor(max_workers=3) as executor:
    results = list(executor.map(fetch, urls))
    print(results)    # [200, 200, 200] — fetched concurrently

ThreadPoolExecutor is useful when you are working with synchronous libraries (like requests) that cannot be used with asyncio. For async-native libraries, use asyncio directly.


A Real-World Pattern: Async HTTP Requests

The most common real-world use of asyncio in Python is making many HTTP requests concurrently. The aiohttp library provides async-native HTTP:

pip install aiohttp
import asyncio
import aiohttp

async def fetch(session, url):
    async with session.get(url) as response:
        return await response.json()

async def fetch_all(urls):
    async with aiohttp.ClientSession() as session:
        tasks = [fetch(session, url) for url in urls]
        return await asyncio.gather(*tasks)

urls = [
    "https://jsonplaceholder.typicode.com/posts/1",
    "https://jsonplaceholder.typicode.com/posts/2",
    "https://jsonplaceholder.typicode.com/posts/3",
]

results = asyncio.run(fetch_all(urls))
for result in results:
    print(result["title"])

All three requests are made concurrently. If each takes 300ms, the total is ~300ms rather than ~900ms.


Summary

ConceptWhat It Is
async defDefines a coroutine function
awaitPauses the coroutine until an awaitable completes
asyncio.run()Runs a coroutine as the top-level entry point
asyncio.gather()Runs multiple coroutines concurrently, waits for all
asyncio.create_task()Schedules a coroutine to run, returns a Task immediately
asyncio.wait_for()Runs a coroutine with a timeout
async withAsync context manager
async forAsync iteration
Event loopSchedules and runs coroutines on a single thread
CoroutineAn async def function that can be paused and resumed
asyncio vs. threadsasyncio for async-native I/O; threads for blocking libraries
asyncio vs. multiprocessingasyncio for I/O-bound; multiprocessing for CPU-bound