Asynchronous Python

Advanced · 18 min read · ▶ live playground · ✦ checkpoint

Some programs feel slow because they are not working; they are waiting. Async Python gives one thread a way to juggle many waiting jobs without creating a thread for each one.

You will use async and await to write cooperative code: each task keeps control while it can work, then hands control back at clear waiting points. In this browser, there is no network, so the examples use asyncio.sleep() as a tiny fake wait.

Async functions make coroutines

An async function is a function defined with async def. Calling it does not run the body right away. It creates a coroutine, an object that represents work that can be run by an async scheduler.

To actually run the coroutine from a normal script, use asyncio.run(). Inside async code, await means "pause here until this awaitable thing is ready, and let other async work run meanwhile." An awaitable is any object Python can wait for with await; coroutines and async tasks are the common ones.

import asyncio
 
 
async def send_receipt(customer):
    print(f"{customer}: preparing receipt")
    await asyncio.sleep(0.01)
    print(f"{customer}: receipt sent")
    return f"{customer} done"
 
 
async def main():
    result = await send_receipt("Mina")
    print("result:", result)
 
 
asyncio.run(main())

Mina: preparing receipt
Mina: receipt sent
result: Mina done

Read that like a paused recipe card. The coroutine starts, reaches await asyncio.sleep(0.01), pauses, and later resumes from the same spot. You saw generators pause with yield; async coroutines pause with await.

One important correction: async does not make a function faster by itself. A single awaited coroutine still runs in order. Async becomes useful when several waiting jobs overlap.

The event loop and tasks

The event loop is the scheduler for async Python. It keeps a list of coroutines and tasks, runs whichever one is ready, and switches away when a task reaches an await that is not ready yet.

A task is a coroutine scheduled on the event loop. asyncio.create_task() says, "Start this coroutine soon, and give me a handle for its future result." asyncio.gather() waits for several awaitables and returns their results in the same order you passed them in.

import asyncio
 
 
async def check_order(order_id, delay):
    print(f"checking {order_id}")
    await asyncio.sleep(delay)
    print(f"finished {order_id}")
    return f"{order_id}: ready"
 
 
async def main():
    tasks = [
        asyncio.create_task(check_order("A12", 0.03)),
        asyncio.create_task(check_order("B34", 0.01)),
        asyncio.create_task(check_order("C56", 0.02)),
    ]
 
    results = await asyncio.gather(*tasks)
 
    print("results:")
    for result in results:
        print(result)
 
 
asyncio.run(main())

checking A12
checking B34
checking C56
finished B34
finished C56
finished A12
results:
A12: ready
B34: ready
C56: ready

Notice the two orders. The tasks finish by delay: B34, C56, A12. The gathered results print by input order: A12, B34, C56. Both are useful, but they answer different questions.

Try it - make waiting overlap

Run this once, then change the delays. Keep the order IDs human-readable so the schedule is easy to see.

python — playgroundlive
⌘/Ctrl + Enter to run

This is concurrency, not parallelism. One event loop is taking turns between tasks. The overlap appears because each task spends time waiting.

Async context managers and iterators

An async context manager is the async version of a with block: it sets something up, lets your block run, then cleans up, with await allowed during setup or cleanup. It uses async with.

An async iterator is the async version of a value you can loop over. It produces items one at a time, but each next item may require waiting. It uses async for.

Here is a tiny fake order session. No network happens here; the sleeps stand in for waiting on some outside service.

import asyncio
 
 
class OrderSession:
    async def __aenter__(self):
        print("open order session")
        await asyncio.sleep(0)
        return self
 
    async def __aexit__(self, error_type, error, traceback):
        await asyncio.sleep(0)
        print("close order session")
 
    async def mark_ready(self, order):
        await asyncio.sleep(0)
        return f"{order} ready"
 
 
class OrderStream:
    def __init__(self, orders):
        self.orders = list(orders)
 
    def __aiter__(self):
        self.position = 0
        return self
 
    async def __anext__(self):
        if self.position == len(self.orders):
            raise StopAsyncIteration
 
        await asyncio.sleep(0)
        order = self.orders[self.position]
        self.position += 1
        return order
 
 
async def main():
    async with OrderSession() as session:
        async for order in OrderStream(["tea-104", "rice-208"]):
            print(await session.mark_ready(order))
 
 
asyncio.run(main())

open order session
tea-104 ready
rice-208 ready
close order session

The method names mirror the sync protocols you already know. __aenter__ and __aexit__ power async with. __aiter__ and __anext__ power async for. StopAsyncIteration is the async loop's stop signal, like StopIteration for regular iterators.

You will usually consume async context managers and iterators from libraries instead of writing them yourself. Web clients, database clients, and streaming APIs often expose this shape because opening, reading, and closing all involve waiting.

Choosing the right model

Use the bottleneck map from this section:

  • Use async when you have many I/O-bound tasks that can pause cooperatively: requests, database calls, streams, timers, chat messages.
  • Use threads when you need to overlap blocking I/O from sync libraries, or when a library does not offer async APIs.
  • Use processes when CPU-bound Python work can be split into independent chunks and needs real parallelism.
  • Use plain sequential code when the program is already clear and fast enough.

Async code has one strict social rule: do not block the event loop. A normal slow function that hogs the thread prevents every other async task from moving. If a task needs to wait, it should await an async operation. If it needs heavy CPU work, it probably belongs in a process or a specialized library.

Another rule is quieter but just as practical: async spreads. Once your code awaits an async function, the caller usually becomes async too. That is why many projects keep async at clear edges such as API handlers, workers, or service clients.

Checkpoint

Answer all three to mark this lesson complete

You now have the three concurrency shapes: threads for overlapping blocking I/O, processes for CPU parallelism, and async for many cooperative waiting tasks. Next, Section 18 shifts from how code runs at the same time to how real Python projects package their dependencies, environments, and releases so other people can run them at all.

+50 XP on completion