Skip to content
← All articles

asyncio.run, Runner, and the Death of get_event_loop

What asyncio.run cleans up that run_until_complete does not, why Runner exists, and the 3.14 change that breaks every tutorial written between 2016 and 2020.

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.