Python — RuntimeError: Event loop is closed
Trace an asyncio closed-loop error to repeated asyncio.run calls, closed clients, or test fixture scope, and fix the lifecycle instead of catching the error.
Symptoms
- RuntimeError: Event loop is closed
- Task was destroyed but it is pending
- The error appears after the first asyncio.run call or at the end of a test suite
- An aiohttp or database client works once and then fails
Possible causes
- asyncio.run is called more than once in the same process
- A loop is closed while tasks or async generators are still pending
- An async client or session is created outside the loop that uses it
- A test fixture closes the loop between tests while resources still reference it
- Synchronous code calls asyncio.get_event_loop in a thread without a running loop
Diagnosis steps
Locate the first closed-loop error in the traceback
python -X dev script.py 2>&1 | grep -B5 -A10 'Event loop is closed'Interpretation
The first occurrence shows which call tried to use the loop after it closed; later errors are often consequences.
Find repeated run or loop lifecycle calls
grep -RIn 'asyncio.run\|new_event_loop\|loop.close\|get_event_loop' .Interpretation
Multiple asyncio.run calls, manual loop closing, and clients created at import time are common causes.
Enable asyncio debug mode
PYTHONASYNCIODEBUG=1 python script.pyInterpretation
Debug mode reports slow callbacks and unawaited tasks, which often reveal the pending work that outlives the loop.
Root cause
Code attempted to use an asyncio event loop after it was closed, or closed a loop while tasks or resources still depended on it. The loop lifecycle does not match the resource lifecycle.
Fix
Use one asyncio.run per process entry point, create async clients inside the running loop, await or cancel pending tasks before closing, and scope test event loops to the test that owns the resources. Do not suppress the error with a broad except.
Risk note
A broad exception handler around a closed-loop error can hide leaked tasks and connections, leading to hangs or data loss at shutdown.
Verify the fix
Run the program or test suite with PYTHONASYNCIODEBUG=1 and confirm there are no pending tasks or closed-loop errors. Add a teardown that cancels remaining tasks and closes clients. Run the suite twice to catch loop reuse across tests.
One loop, one lifetime
asyncio.run() creates a new event loop, runs the coroutine, and closes the loop. Calling it twice in one process is fine only if nothing from the first run survives. Async clients, sessions, and background tasks often do survive, and they try to use the closed loop later.
async def main():
async with httpx.AsyncClient() as client:
await fetch_all(client)
asyncio.run(main())
Create clients inside main, not at module import. Await or cancel background tasks before returning. Do not keep a reference to the loop and reuse it across asyncio.run calls.
Tests need matching scope
A test fixture that creates a client in one loop and closes it in another produces this error. Use an async fixture with pytest-asyncio, keep the event loop scope aligned with the resource, and close clients in the same loop. Running tests twice in one process can reveal leaked loop state.
Shut down pending work
On shutdown, cancel remaining tasks and await their cancellation. asyncio.all_tasks() shows what is still running. A task destroyed while pending may log a warning and lose work. The goal is a clean loop shutdown, not an exception handler that hides the symptom.
Verify with debug mode
Run with PYTHONASYNCIODEBUG=1 and a warnings-as-errors setting. Confirm no pending tasks remain and the suite passes when run repeatedly. If the error persists, find the object created outside the loop; that is the lifecycle mismatch.