Fix "RuntimeError: asyncio.run() cannot be called from a running event loop"

Fix "RuntimeError: asyncio.run() cannot be called from a running event loop"

I ran into this the first time inside a Jupyter cell that had nothing async-looking in it at all, just a call to a helper function three imports deep that happened to wrap asyncio.run(). The message is asyncio refusing to start a second event loop in a thread where one is already running. Below is the reproduction I actually used to understand it (tested against CPython 3.12.3), the fix that applies most of the time, and the one case where reaching for nest_asyncio is a reasonable call instead of a cop-out.

Reproducing the Error

The error needs a running event loop to already exist before the offending call happens. The cleanest way to reproduce it outside a notebook is to call asyncio.run() from inside a coroutine that's already executing on a loop:

import asyncio

async def fetch_data():
    return 42

async def process():
    # already running inside main()'s event loop; this line is the bug
    result = asyncio.run(fetch_data())
    return result

asyncio.run(process())
Traceback (most recent call last):
  File "app.py", line 9, in 
    asyncio.run(process())
  File "app.py", line 6, in process
    result = asyncio.run(fetch_data())
RuntimeError: asyncio.run() cannot be called from a running event loop

Nothing about fetch_data() is wrong. The problem is entirely that process() tries to start a brand new loop with asyncio.run() while it's already executing inside one that asyncio.run(process()) started on the outer line.

Why This Happens: What asyncio.run() Actually Does

asyncio.run() bundles three steps into one call: create a new event loop, run the given coroutine on it to completion, then close the loop. Trace it into CPython's own source (asyncio/runners.py) and the top-level run() function's first two lines are if events._get_running_loop() is not None: raise RuntimeError("asyncio.run() cannot be called from a running event loop"), the exact message this article is about, raised before any of the actual loop-creation work even starts. It's the same defensive pattern you'd write yourself before grabbing an exclusive lock that's already held: fail loudly and immediately, rather than let two loops start fighting over the same thread and produce a much harder bug to track down later.

The Most Common Trigger: Jupyter and IPython

This is where most developers meet this error for the first time, often with no asyncio.run() written anywhere in their own cell. Jupyter's kernel (ipykernel) runs on top of an asyncio event loop that stays active for the entire notebook session, so any code calling asyncio.run() inside a cell is, from asyncio's point of view, in exactly the same situation as the nested example above:

# inside a Jupyter cell
import asyncio

async def main():
    return "done"

asyncio.run(main())
# RuntimeError: asyncio.run() cannot be called from a running event loop

The same underlying situation shows up outside notebooks too, anywhere a framework already owns the event loop before your code runs: inside a FastAPI request handler served by uvicorn, inside an aiohttp route, or inside a handler registered with an async Telegram/Discord bot library. In every one of these, something else already called the equivalent of asyncio.run() to start the loop your code is executing inside.

The Correct Fix: await Instead of asyncio.run()

The fix in almost every real case is to stop trying to start a new loop and use the one that's already running, by awaiting the coroutine directly:

import asyncio

async def fetch_data():
    return 42

async def process():
    result = await fetch_data()  # no new loop, just hands control to the existing one
    return result

asyncio.run(process())
print("worked")

This applies directly to the Jupyter case too. If main() above is itself a coroutine, the fix isn't to wrap it in asyncio.run() at all; a Jupyter cell can await a top-level coroutine directly, since the kernel's own loop is already there to run it:

# inside a Jupyter cell, no asyncio.run() needed
import asyncio

async def main():
    return "done"

result = await main()
print(result)

Fire-and-Forget From Inside a Running Loop: create_task()

Sometimes the goal isn't to wait for a result inline, but to schedule a coroutine to run concurrently with whatever else the running loop is doing, for example dispatching a background notification without blocking the current handler. asyncio.get_running_loop().create_task() (or the module-level asyncio.create_task() shortcut) schedules a coroutine on the already-running loop instead of asking for a new one:

import asyncio

async def send_notification():
    await asyncio.sleep(0.01)
    print("notification sent")

async def handle_request():
    loop = asyncio.get_running_loop()
    loop.create_task(send_notification())  # scheduled, doesn't block this line
    return "request handled immediately"

asyncio.run(handle_request())

This is the pattern behind most "dispatch an async task from inside a sync-looking callback that's actually running on a loop" situations, and it's the correct tool anywhere asyncio.run() was being reached for out of habit rather than an actual need to wait for the result.

When nest_asyncio Is the Right Call, and When It Isn't

The nest_asyncio package patches asyncio's internals so a nested asyncio.run() call is allowed to succeed instead of raising:

import asyncio
import nest_asyncio
nest_asyncio.apply()

async def fetch_data():
    return 42

async def process():
    result = asyncio.run(fetch_data())  # now succeeds
    return result

asyncio.run(process())

Version 1.6.0 (the current release as of this writing, and the one tested for this article) works by patching asyncio.base_events.BaseEventLoop._run_once and a handful of other internals so a loop tolerates being re-entered. That's a reasonable trade in a notebook, where rewriting every cell to consistently await instead of calling asyncio.run() means hunting down code pasted in from a dozen different sources over the life of the notebook. I'd reach for it there without hesitation. Inside an actual application, though, I'd treat the same patch as a smell: something upstream (a library, or code copied from a script) is calling asyncio.run() in a place it was never meant to run, and fixing that call site directly usually takes less effort than it looks like from the traceback.

Which One Actually Applies to You

If you can see the offending asyncio.run() call and you're already inside an async def, delete it and await the coroutine instead, that's the fix in the overwhelming majority of real cases, including the Jupyter one, since a notebook cell can await a top-level coroutine without wrapping it in anything. If the goal was never to wait for the result inline but to kick something off in the background, create_task() on the running loop is the tool, not asyncio.run(). The remaining case, where the call is buried in a library or a script you're pasting into an already-running notebook and rewriting isn't practical right now, is what nest_asyncio is actually for.