Asynchronous runtimes & concurrency
Node's event loop is always running; Python's is not. Lazy coroutines, explicit scheduling, and the GIL's effect on which concurrency primitive you actually want.
The syntax is nearly identical. async def, await, the same shape of code. The runtime model is
not, and that is where the bugs come from.
Nothing runs until you start a loop
Node boots an event loop before your first line executes. Python has none until you ask for one, and a coroutine that is never awaited never runs at all.
The silent no-op
Calling an async def and discarding the result does nothing, no request, no error, just a
RuntimeWarning: coroutine was never awaited if you are lucky. In JavaScript the equivalent fires
the request and swallows the rejection. Both are bugs; Python's is quieter at runtime and louder
at review time.
Eager Promises, lazy coroutines
| JavaScript Promise | Python coroutine | |
|---|---|---|
| Starts executing | on construction | on await, or when wrapped in a task |
| Awaiting twice | returns the cached result | RuntimeError: cannot reuse already awaited |
| Fire and forget | void p | asyncio.create_task(coro) |
To get Promise-like eagerness, schedule the coroutine explicitly:
Combinators
| JavaScript | Python | Behaviour |
|---|---|---|
Promise.all([p1, p2]) | asyncio.gather(t1, t2) | concurrent; raises on the first failure |
Promise.allSettled([...]) | asyncio.gather(..., return_exceptions=True) | collects results and exceptions |
Promise.race([...]) | asyncio.wait(..., return_when=FIRST_COMPLETED) | returns when the first finishes |
AbortSignal.timeout(ms) | async with asyncio.timeout(s) | 3.11+, raises TimeoutError |
Structured concurrency with TaskGroup
Promise.all fails fast but abandons its siblings, they keep running, their results discarded,
and nothing awaits them again. Python 3.11+ has a stronger contract. It is called structured concurrency.
An asyncio.TaskGroup scope does not exit until every task started inside it is finished,
or cancelled, if one of them failed first.
Promise.all + AbortSignal | asyncio.TaskGroup | |
|---|---|---|
| First failure | rejects; siblings keep running | cancels every sibling, then raises |
| Error shape | the single first rejection | ExceptionGroup, all failures, collected |
| Cancellation wiring | you thread the signal through by hand | automatic within the scope |
| After the block | may still have in-flight requests | guaranteed: no task survives the block |
except* is a new keyword shape
A TaskGroup failure surfaces as an ExceptionGroup, and you catch it with except*, a separate
syntax that unwraps the group and matches each member exception type independently. Plain except ValueError will not catch it, because a group of ValueErrors is not itself a ValueError. Also
note return is illegal inside an except* block, assign a result and return after, as the
snippet does.
The rule of thumb is this. Use gather for quick fan-outs where abandonment is fine (or where you asked for
return_exceptions=True), TaskGroup whenever sibling tasks share a lifetime, which is most
request handlers.
The GIL
CPython holds a global lock around bytecode execution, so threads do not give you CPU parallelism. They do give you concurrency during blocking I/O, because the lock is released while waiting.
| Workload | Reach for | Why |
|---|---|---|
| Network / disk I/O | asyncio | thousands of waits, one thread, no lock contention |
| Blocking library with no async API | asyncio.to_thread / threading | the GIL is released during the blocking call |
| CPU-bound number crunching | multiprocessing | separate interpreters, separate GILs |
| CPU-bound array maths | numpy | the heavy loops release the GIL in C |
PEP 703 makes the GIL optional. In 3.14 the free-threaded build is supported but still optional, the GIL remains the default, and whether it ever becomes the default build is undecided. It does not change the advice above. Most C extensions still assume the lock exists.
Kata
The pattern you will write in your first week on any Python service. Fan out, but not too far.
Scoping, closures & dunder protocols
Replace the prototype chain with Python's explicit object model. LEGB lookup, nonlocal, and the dunder methods that make your classes feel built-in.
Typing, modern tooling & testing
TypeScript's structural types map onto Protocol and TypedDict. Then swap npm, ESLint, Prettier and Jest for uv, ruff and pytest.