Whetstone.
The Idioms You Will MeetAsync Python, and where it is not Node
Module 4 · Lesson 317 min

Async Python, and where it is not Node

The syntax is the same as yours. async def, await, and it reads identically. The runtime underneath is different in four ways that matter, and the ecosystem around it is different in one way that matters more than all four.

The syntax you already know

This will not surprise you
import asyncio
import httpx

async def fetch_user(client: httpx.AsyncClient, user_id: int) -> dict:
    response = await client.get(f"/users/{user_id}")
    return response.json()

async def main() -> None:
    async with httpx.AsyncClient(base_url="https://api.example.com") as client:
        users = await asyncio.gather(*(fetch_user(client, i) for i in range(5)))
        print(users)

asyncio.run(main())

async with and async for are the awaitable versions of the two protocols from the last lesson, backed by __aenter__/__aexit__ and __aiter__/__anext__. Same idea, extra letter.

Difference one: coroutines are lazy

Calling an async function runs none of its body. You get a coroutine object; the body starts when something awaits it or schedules it.

The difference in one line
coro = fetch_user(client, 1)   # nothing has happened
result = await coro            # NOW it runs

In JavaScript, calling an async function starts it immediately and the Promise is a handle on work already in flight. In Python the coroutine is a recipe, not a receipt.

The practical consequences:

  • A forgotten await in JavaScript still does the work and merely loses the result. In Python it does nothing at all. Python does warn (coroutine ... was never awaited), and that warning is worth treating as an error.
  • Fire and forget needs asyncio.create_task(coro), which schedules it on the loop. Just calling the function achieves nothing. And you must keep a reference to the task, because the loop only holds a weak one and an unreferenced task can be garbage collected mid-flight.

Difference two: there is not always a loop

Node has one event loop and it is always running. Your program is already inside it.

Python starts as an ordinary synchronous program with no loop at all. asyncio.run(main()) creates one, runs the coroutine until it completes, and shuts it down.

The entry point, and the two ways to get it wrong
asyncio.run(main())        # correct: one loop, created and closed here

asyncio.run(a())           # works, but this is two separate loops
asyncio.run(b())           # and anything bound to the first is now dead

And there is no top-level await in a module. Every await has to be inside an async def. (python -m asyncio gives you a REPL that allows it, which is handy and is not how modules work.)

Calling asyncio.run from inside a coroutine raises, because a loop is already running on that thread. That error, asyncio.run() cannot be called from a running event loop, is what you get when a synchronous helper deep in a call stack tries to run something async, and the only real fix is to make the caller async.

Difference three: blocking is catastrophic and easy

The loop is a single thread running coroutines cooperatively. A coroutine gives up control only at an await. Anything else runs to completion first.

One line that stalls the whole service
async def handler(url: str) -> str:
    return requests.get(url).text     # blocking. Nothing else runs. At all.

Every other in-flight request on that process is frozen for the duration.

Node has the same property and you are protected from it by an ecosystem where practically every IO library is already async. Python’s is half-and-half, so you can install a blocking library and use it inside a coroutine with no error, no warning, and a service that quietly performs like a synchronous one.

The two fixes
async def handler(url: str) -> str:
    return (await client.get(url)).text                 # use an async client

async def handler_legacy(url: str) -> str:
    return await asyncio.to_thread(requests.get, url)   # or push it to a thread

asyncio.to_thread is the escape hatch for a blocking call you cannot replace, and it is also the right tool for a CPU-bound chunk you do not want on the loop.

Difference four: gather against TaskGroup

The old way
results = await asyncio.gather(fetch(1), fetch(2), fetch(3))

Fine, and it has a sharp edge. With default settings, if one raises, the exception propagates to the awaiter and the other tasks are neither cancelled nor awaited. They run on unsupervised, and their own failures surface later as unretrieved-exception warnings from nowhere in particular.

The modern way, 3.11 and later
async with asyncio.TaskGroup() as tg:
    t1 = tg.create_task(fetch(1))
    t2 = tg.create_task(fetch(2))
    t3 = tg.create_task(fetch(3))

results = [t1.result(), t2.result(), t3.result()]

TaskGroup guarantees that leaving the block means every child task is finished. If one fails, the rest are cancelled and everything that went wrong is reported together as an ExceptionGroup, which is what except* was added to catch. This is structured concurrency: task lifetimes nest inside a block instead of escaping it.

gather(..., return_exceptions=True) is still the right tool when you genuinely want every result including the failures, in order, as values rather than as raised exceptions.

Timeouts are also a block:

Cancelling on time
async with asyncio.timeout(5):
    await slow_thing()

The thing that actually costs you time: two colours

A coroutine can only be awaited from a coroutine, so async is contagious upwards through every caller. Python did not start out async, so a library that wants to serve both worlds ships two parallel APIs.

Sync Async
requests httpx (which does both), aiohttp
psycopg psycopg async, asyncpg
open() aiofiles
time.sleep asyncio.sleep

In the frameworks you are heading for, the convention is a leading a:

The naming convention across LangChain and LangGraph
graph.invoke(state)          graph.ainvoke(state)
graph.stream(state)          graph.astream(state)
model.batch(inputs)          model.abatch(inputs)

Pick one colour per entry point and stay in it. A sync entry point that needs one async call has bad options, because asyncio.run in the middle of a program raises if a loop is already running. Mixed-colour code is the most common structural mess in Python services, and unlike almost everything else in this course, it is not something you can fix locally once it has spread.

Where this goes

That is the course. You have the spelling, a toolchain that is one command deep, the type system and its honest limits, and the four protocols that make agent code readable.

The capstone is a port. It is deliberately small, because the gap between reading Python and writing it is one evening wide and the only way across is to spend the evening.

Practice

Try it yourself

Quiz

Calling an async function without awaiting

You write result = fetch_user(1) where fetch_user is async def, and never await it. What has happened?

  1. ANothing has run. You hold a coroutine object, and Python warns that it was never awaited
  2. BThe body has started and is running concurrently, exactly as an unawaited Promise would be
  3. CIt raises RuntimeError, because a coroutine cannot be created outside a running event loop
  4. DThe body ran to completion synchronously, since without an await point there is nothing to suspend on
Show answer

Correct answer: A — Nothing has run. You hold a coroutine object, and Python warns that it was never awaited

A coroutine is lazy. Calling the function builds an object and runs none of the body, and only awaiting it or scheduling it as a task starts execution. This is the single biggest difference from JavaScript, where calling an async function starts it immediately and the Promise is a handle on work already in flight. The practical consequence is that a forgotten await in JavaScript still does the work and only loses the result, while in Python it does nothing at all, so the fire-and-forget pattern you may be used to needs asyncio.create_task here.

Quiz

A synchronous call inside a coroutine

Inside an async def handler you call requests.get(url), which is a blocking library. What is the effect?

  1. AOnly that coroutine blocks; the loop continues running the others, since each coroutine has its own stack
  2. BThe entire event loop stalls for the duration, so every other coroutine on that loop stops making progress
  3. CThe call is automatically moved to a thread pool, because the loop detects blocking IO and offloads it
  4. DIt raises RuntimeError, since a blocking call inside a coroutine is detected and refused by the runtime
Show answer

Correct answer: B — The entire event loop stalls for the duration, so every other coroutine on that loop stops making progress

The event loop is a single thread running coroutines cooperatively, so a coroutine only yields control at an await. A blocking call has no await point, so nothing else runs until it returns, and one slow synchronous HTTP call stalls every concurrent request on the process. The fix is await asyncio.to_thread(blocking_fn, ...) or a genuinely async client. The belief that the loop automatically offloads a blocking call to a thread pool is what people assume must be happening because the failure is otherwise so easy to cause, and it is exactly the assumption that produces an async service with the throughput of a synchronous one.

Quiz

When one of several concurrent tasks fails

Five coroutines run concurrently and the second raises. What differs between asyncio.gather with its default settings and asyncio.TaskGroup?

  1. Agather cancels the remaining tasks on first failure; TaskGroup lets them all finish anyway
  2. BThey behave identically. TaskGroup is newer syntax for the same semantics in a block form
  3. Cgather propagates the first exception and lets the rest run; TaskGroup cancels its siblings
  4. Dgather returns exceptions beside the results; TaskGroup raises one and discards the others
Show answer

Correct answer: C — gather propagates the first exception and lets the rest run; TaskGroup cancels its siblings

This is the difference that matters operationally. With default settings gather raises the first exception to its awaiter, but the remaining tasks are neither cancelled nor awaited, so they run on unsupervised and their own failures surface as unretrieved-exception warnings much later. TaskGroup guarantees that leaving the block means every child is finished, cancelling the rest on first failure and reporting everything as one ExceptionGroup. Describing gather as returning exceptions beside the results is gather(..., return_exceptions=True), which is a real and useful mode and not the default.

Recall

The sync and async split

A structural fact about the ecosystem rather than about the syntax, and the thing that will actually cost you time.

Why do Python libraries ship both a sync and an async version of the same API, and what does that look like in the agent frameworks?

Reveal answer

A coroutine can only be awaited from another coroutine, so async is contagious upwards through every caller. Since Python did not start out async, a library that wants to serve both worlds has to expose two parallel APIs, which is why requests and httpx, psycopg sync and async, and sqlalchemy both ways all exist. In LangChain and LangGraph the convention is an a prefix: invoke and ainvoke, stream and astream, batch and abatch. The practical rule is to pick one colour per entry point and stay in it, since mixing means either blocking the loop with a sync call or reaching for asyncio.run in the middle of a program, and the second one raises if a loop is already running.

Check

Measure sequential against concurrent

Write a coroutine that sleeps one second, call it five times sequentially with awaits in a loop, then five times through asyncio.gather. Time both.

You should see

Roughly five seconds against roughly one, and you can point at the line in the sequential version that made it sequential.

Sign in to track your progress →