Between 2016 and 2020, essentially every asyncio tutorial opened the same way:
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
In Python 3.14 the first line raises RuntimeError when there is no
current event loop. Not a DeprecationWarning — that shipped in 3.10 and 3.12
— a hard failure. And in 3.16 the entire event-loop policy system goes
away.
If you maintain anything written in that era, this is the migration that will bite, and the fix is usually one line.
What asyncio.run actually does
asyncio.run(coro, *, debug=None, loop_factory=None)
In order: create a new event loop; run coro to completion; then, on the way
out, finalise all async generators, shut down the default thread-pool
executor (documented with a five-minute timeout — a hang here is almost
always a blocking call still running in to_thread), and close the loop.
That cleanup list is the reason run is not just sugar for
loop.run_until_complete. An async generator suspended at a yield has a
finally block that has not executed; without finalisation, its aclose()
never runs and whatever it holds — a file handle, a database cursor, a
connection checked out of a pool — is released only when the garbage collector
gets round to it, if ever.
asyncio.run is also one-shot. It creates a loop, uses it, closes it. Two
calls give you two loops. Anything cached against the first loop — a
aiohttp.ClientSession, an asyncpg pool, an asyncio.Lock — is bound to a
dead loop by the time the second call runs, and the error you get
(... is bound to a different event loop) points at the object, not at the
run that orphaned it.
asyncio.Runner, for when one call is not enough
3.11 added the multi-call version:
with asyncio.Runner() as runner:
a = runner.run(first())
b = runner.run(second())
Same loop across both calls, same contextvars.Context preserved between them,
same cleanup semantics at __exit__. This is the right tool for a synchronous
CLI or a test fixture that needs several async calls to share loop-bound state.
loop_factory= on both run and Runner is where you plug in uvloop.
💡A sync library exposes def fetch(url) -> bytes and implements it as asyncio.run(_fetch(url)). A user calls it inside their own async handler. What happens, and what should the library have done?
click to reveal
asyncio.run raises RuntimeError: asyncio.run() cannot be called from a running event loop. The user’s handler dies, and the traceback points into
your library at a line that looks perfectly reasonable.
Under the older get_event_loop().run_until_complete() spelling, the failure
was worse: on some paths it would find the running loop and call
run_until_complete on it, which raises RuntimeError: This event loop is already running — and on others it would create a second loop in the same
thread, run to completion there, and return a plausible-looking answer computed
against a loop that shares nothing with the caller’s. Silently wrong beats
loudly wrong every time, which is why 3.14 made the no-loop case raise.
What the library should have done is expose the async function as the primary
API and the sync wrapper as a clearly named convenience — fetch_sync — that
checks first. That is exactly this problem’s run_sync. The check is per
thread: a worker thread has no running loop of its own, so an async caller
can always reach the sync API through asyncio.to_thread.
The migration table
| Old | New |
|---|---|
asyncio.get_event_loop() at module level |
delete it; take the loop from inside a coroutine |
loop.run_until_complete(main()) |
asyncio.run(main()) |
asyncio.get_event_loop() inside a coroutine |
asyncio.get_running_loop() |
loop.create_task(c) |
asyncio.create_task(c) (or a TaskGroup) |
asyncio.set_event_loop_policy(...) |
asyncio.run(main(), loop_factory=...) |
get_running_loop() is the one to internalise. It never creates anything, it
raises RuntimeError if there is no running loop, and that error is precisely
the signal “this code is not running under a loop” — which is a fact worth
failing on rather than papering over.
💡asyncio.run(main()) versus asyncio.run(main) — the second is a typo people make. Which layer catches it?
click to reveal
The type checker, at the call site, before you run anything.
asyncio.run is typed (Coroutine[Any, Any, T], *, ...) -> T. Passing the
function object rather than calling it gives mypy a
Callable[[], Coroutine[Any, Any, None]] where a Coroutine is required, and
it is an arg-type error.
At runtime you would get ValueError: a coroutine was required, which is fine
here — but the same typo one level down, await self.refresh instead of
await self.refresh(), is not caught at runtime at all in the general case,
because a bare coroutine function object is truthy and awaiting is not always
where you notice. That whole family of bugs is what the Coroutine/Awaitable
annotations exist to catch, and it is the subject of the next item.