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
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.
coro = fetch_user(client, 1) # nothing has happened
result = await coro # NOW it runsIn 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
awaitin 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.
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 deadAnd 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.
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.
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 threadasyncio.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
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.
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:
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:
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.
Try it yourself
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?
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.
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?
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.
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?
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.
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.
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.
Roughly five seconds against roughly one, and you can point at the line in the sequential version that made it sequential.