RuntimeError: Event loop is closed

Python · updated 2026-11-02

Raised when code calls into an asyncio event loop that has already been closed. It often shows up at shutdown or between tests, when an HTTP or database client created on one loop is cleaned up or reused after that loop ended. On Windows it is common at interpreter exit with the default proactor loop.

Traceback (most recent call last):
  File "/srv/app/jobs/sync.py", line 41, in run_batch
    return asyncio.run(fetch_all(urls))
  ...
  File "/usr/lib/python3.12/asyncio/base_events.py", line 541, in _check_closed
    raise RuntimeError('Event loop is closed')
RuntimeError: Event loop is closed

Quick fix

Create async clients inside the loop that uses them and close them before it ends (async with), and keep one loop per process or per request instead of calling asyncio.run repeatedly on shared objects. Inside a framework that already runs a loop (FastAPI, Jupyter, Celery async workers), await the coroutine instead of calling asyncio.run.

  1. Find which object touched the loop: the traceback usually ends in a client close() or a transport callback.
  2. Check whether that client was created at import time or in a previous asyncio.run call.
  3. Create and close it inside the same coroutine, with async with.
  4. Replace repeated asyncio.run calls on shared state with one long-lived loop, or await directly inside async code.
  5. In tests, use the async plugin’s loop (pytest-asyncio) and close clients in fixtures.
python
import asyncio, httpx

async def fetch_all(urls):
    async with httpx.AsyncClient(timeout=10) as client:   # lives and dies with this loop
        return await asyncio.gather(*(client.get(u) for u in urls))

def run_batch(urls):
    return asyncio.run(fetch_all(urls))                    # no module-level client reused across runs

Why it happens

A client created on one loop and used or closed on another

A module-level aiohttp/httpx/database client binds to the loop that first used it. The next asyncio.run creates a new loop, and closing or reusing the old client touches the closed one.

Calling asyncio.run more than once around shared objects

Each asyncio.run creates and closes a loop. Objects that outlive one call keep references to a loop that no longer exists.

Cleanup after the loop ended

Destructors (__del__) or atexit handlers that schedule async cleanup run after the loop is closed.

Test isolation

Each test gets a new loop, but a client cached in a fixture or module from an earlier test still points at the old one.

Other fixes

  • Close async clients explicitly (await client.aclose()) in your shutdown hook, before the loop stops.
  • Avoid module-level async clients; create them in an app lifespan or startup handler.
  • In sync code that needs async calls often, run one background loop thread instead of many asyncio.run calls.

Same error, other wording

  • RuntimeError: Event loop is closed (in aiohttp ClientSession.__del__)
  • RuntimeError: Task got Future attached to a different loop
  • RuntimeError: asyncio.run() cannot be called from a running event loop

Let your coding agent catch the next one

Runheed records this error in production with the stack trace, the breadcrumbs before it and the agent session that shipped the code, and serves it to Claude Code, Cursor or any MCP client. With it connected, you can ask:

> Find the "Event loop is closed" errors in production, show which client or callback touched the loop and where it was created, and change it so the client is created and closed inside the loop that uses it.

Plans from $29/moSee what your agent readsSee the setupCancel any time from the billing portal

Related errors

References: Python: asyncio.run, Python: asyncio event loop