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 closedQuick 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.
- Find which object touched the loop: the traceback usually ends in a client close() or a transport callback.
- Check whether that client was created at import time or in a previous asyncio.run call.
- Create and close it inside the same coroutine, with async with.
- Replace repeated asyncio.run calls on shared state with one long-lived loop, or await directly inside async code.
- In tests, use the async plugin’s loop (pytest-asyncio) and close clients in fixtures.
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 runsWhy 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.
Related errors
- requests.exceptions.ConnectionError: HTTPSConnectionPool(host='api.example.com'...
- ImportError: cannot import name 'x' from partially initialized module 'y' (most likely...
- AttributeError: 'NoneType' object has no attribute 'x'
References: Python: asyncio.run, Python: asyncio event loop