Watch at EaseStories. Answers. Perspective.

Technology · PRACTICAL GUIDE

asyncio.run() failed inside Jupyter or FastAPI? Fix the loop owner, not the symptom

The exception is an ownership alarm: something on this thread already runs the event loop. Await inside that owner, or create one loop only at the true synchronous entry point.

Original decision matrix for choosing asyncio.run, await, or separate library entry points according to the event-loop owner.
Original illustration: identify the loop owner before choosing the asyncio handoff. Watch at Ease

Python raises this error before it starts a second loop in the same thread. That is deliberate: asyncio.run() owns creation, execution, asynchronous-generator cleanup, executor shutdown and loop closure. A notebook kernel or web server already owns those responsibilities.

The safe fix depends on the caller. In a notebook cell or async route, await the coroutine. In a standalone script, keep one asyncio.run() at the process boundary. In a reusable library, expose an async API and make any synchronous wrapper fail clearly when a loop is already running.

Name the loop owner before changing code

At the failing call, record the thread, whether asyncio.get_running_loop() succeeds, and which host invoked the function. A notebook cell, an async web handler, a framework lifespan hook and a plain command-line entry point have different ownership contracts.

Do not call stop(), close(), run_until_complete() or asyncio.run() on a loop you did not create. The error usually means the boundary is misplaced, not that asyncio itself needs resetting.

Use await inside notebooks and async frameworks

In Jupyter or IPykernel, replace result = asyncio.run(work()) with result = await work() in the cell. In FastAPI, Starlette, Uvicorn or another async framework, make the caller async and await the operation in the framework-owned loop.

If the operation is long-lived, bind its Task to the host lifecycle and define startup, cancellation and shutdown. Do not create a detached task merely to make the exception disappear.

Keep one top-level owner in a script

A standalone synchronous process can keep asyncio.run(main()) under the true entry point. If several top-level async calls must share loop and context state, one asyncio.Runner context may own them sequentially.

Do not sprinkle runners through helper functions. Ownership should be visible at the outer boundary, leaving the inner implementation as awaitable code.

Design libraries with separate async and sync contracts

Make the primary operation async, such as async def fetch_async(...). A synchronous fetch(...) wrapper may call asyncio.run(fetch_async(...)) only when no loop is running in that thread; otherwise it should raise an actionable error telling the caller to await fetch_async.

Perform the running-loop check before constructing the coroutine, or close the unused coroutine deliberately on the error path. This avoids the misleading “coroutine was never awaited” warning that can hide the first ownership failure.

Use cross-thread submission only for a real thread boundary

When a dedicated loop runs in another OS thread and synchronous code must submit work to it, asyncio.run_coroutine_threadsafe(coro, loop) returns a concurrent Future. Timeouts, cancellation and shutdown ownership still need an explicit contract.

Calling that API from the loop thread and blocking on future.result() can deadlock. The thread identity and loop lifecycle must be part of the design, not inferred at runtime.

Treat loop patching as a contained compatibility decision

A nesting patch may be unavoidable for a legacy tool, but it should be an explicit, version-bounded adapter with a minimal scope and tests. Do not apply it process-wide as the default library behavior.

Recent project reports show why: code may return a value yet leave context-entry errors, pending tasks or later-cell failures. A successful result is not enough to prove loop integrity.

Prove the repaired boundary

Test the async API under an already-running loop and the sync wrapper in a plain thread. Verify return values, original exceptions, cancellation, context variables, repeat calls and that asyncio.all_tasks() contains no unexpected survivors after cleanup.

Add a negative test: calling the sync wrapper from inside a running loop must fail immediately with the documented instruction to await the async API. That turns future framework or dependency changes into a local test failure instead of a production kernel or server incident.

Original diagram showing one async implementation with separate async and synchronous entry contracts that never nest event loops.
Original illustration: one async implementation can serve explicit async and sync boundaries without nesting loops. Watch at Ease