Python

RuntimeError: Event loop is closed

Written and reviewed by Sahil Srivastav

asyncioLifecyclePython
RuntimeError: Event loop is closed
  File "/usr/lib/python3.11/asyncio/base_events.py", line 515, in _check_closed
    raise RuntimeError('Event loop is closed')
Exception ignored in: <function BaseSubprocessTransport.__del__>

What this error actually means

Every asyncio primitive — a task, a transport, a lock, a connection pool — is bound to the loop that created it. Closing a loop does not unbind them; it only makes them unusable. This error means something still holding a reference to a closed loop tried to use it, most often to schedule a callback or to tear down a socket.

The single biggest source is `asyncio.run`. It creates a fresh loop, runs one coroutine, then cancels remaining tasks, shuts down async generators and closes the loop. Anything you created inside that call and stored somewhere outside it — a client session, an engine, a pool cached on a module-level variable — now points at a dead loop. The second `asyncio.run` in the same process gets a different loop, and the cached object raises the moment it touches the old one.

The other common shape is an object whose `__del__` needs the loop. When the garbage collector finalises a transport or a subprocess after shutdown, the finaliser tries to schedule cleanup on a loop that no longer exists. Python prints `Exception ignored in: <function ... .__del__>` — which is why this frequently appears *after* your program has finished, with no relation to any request, and is so often dismissed as cosmetic. It is not: it usually means a connection was never closed properly.

Causes, most common first

  1. 1A long-lived object created inside asyncio.run and cached outside it. The dominant cause. A module-level `aiohttp.ClientSession`, an `httpx.AsyncClient`, a Redis or SQLAlchemy async engine created lazily on first use inside one `asyncio.run` and reused by the next. The object is fine; the loop it captured is gone.
  2. 2asyncio.run called more than once, often indirectly. A synchronous wrapper that calls `asyncio.run` internally, used in a loop or called from several code paths. Each call is a whole loop lifecycle. Common when async code is bolted onto a sync codebase one function at a time.
  3. 3Transports and subprocesses finalised after the loop closed. Sockets, subprocess transports and SSL transports need the loop in their cleanup path. If they are still alive when the loop closes, the finaliser raises at garbage-collection time — which is why the message is emitted with no traceback into your code.
  4. 4Test fixtures spanning loops. A session-scoped fixture that builds an async client while individual tests get function-scoped loops. The fixture outlives the loop it was created on, so every test after the first fails in a way that looks like flakiness.
  5. 5Background work still running at shutdown. Tasks created with `asyncio.create_task` and never awaited are cancelled by `asyncio.run` on the way out. If their cleanup needs to await anything, it runs against a loop that is already closing.

When you see it

  • It appears at interpreter exit, after the last line of output, with `Exception ignored in` above it
  • The first call to a function works and the second raises, because each call created and closed its own loop
  • Only visible under pytest, where each async test gets a fresh loop and cached clients survive between them
  • A cached HTTP client or database engine is in the traceback rather than your own code
  • It coincides with `Task was destroyed but it is pending!` messages

How to diagnose it

Step 1

Find out how many loops the process creates

Log the loop identity at the top of each entry point. Two different values in one process is the whole diagnosis, and it takes one line to establish.

import asyncio; print(id(asyncio.get_running_loop()))

Step 2

Grep for every asyncio.run in the call path

More than one `asyncio.run` in a process is nearly always the bug, especially when one of them is inside a library wrapper or a sync-compatibility shim.

grep -rn 'asyncio.run(\|run_until_complete(\|new_event_loop(' --include='*.py' .

Step 3

Turn on asyncio debug mode

Debug mode attaches the creation traceback to objects that are destroyed in a bad state, so the `Exception ignored in __del__` message gains the line that created the unclosed resource.

PYTHONASYNCIODEBUG=1 python -X dev app.py

Step 4

List what is still pending at shutdown

If tasks remain when your main coroutine returns, they will be cancelled abruptly. Printing them names the work you forgot to await or cancel.

print([t.get_coro() for t in asyncio.all_tasks() if not t.done()])

The fix

Have exactly one `asyncio.run` per process, at the top. Everything async lives inside it, and synchronous callers are pushed to the edges. If a sync function needs async work, restructure so the async side owns the entry point rather than wrapping `asyncio.run` deeper in the stack — that wrapping is what multiplies loops.

Create loop-bound clients inside the loop and close them inside it too. `async with aiohttp.ClientSession() as session:` around the lifetime of the work, or a lifespan hook in FastAPI/Starlette that creates the client on startup and awaits its `aclose()` on shutdown. Lazily caching a client on a module global is exactly the pattern that breaks.

Await or cancel every background task before the loop closes. Keep strong references to tasks you create, then `await asyncio.gather(*tasks, return_exceptions=True)` — or cancel them and await the cancellation — in your shutdown path. A task you never reference can also be garbage collected mid-flight, which is a separate and worse bug.

For the finaliser noise, close resources explicitly rather than leaving them to `__del__`. Every transport, subprocess and connection pool should have a deterministic `close()`/`aclose()` awaited before shutdown. If the message persists, the remaining offender is almost always a library client you construct but never close.

In tests, match fixture scope to loop scope. With `pytest-asyncio`, a client fixture must be function-scoped unless the loop is session-scoped too; mixing the two is the standard cause of "the second test always fails".

# Module-level client captures whichever loop ran first
_client = None

async def fetch(url):
    global _client
    if _client is None:
        _client = httpx.AsyncClient()      # bound to this loop forever
    return await _client.get(url)

def sync_fetch(url):
    return asyncio.run(fetch(url))         # second call -> Event loop is closed

# One loop; the client lives and dies inside it
async def main(urls):
    async with httpx.AsyncClient(timeout=5.0) as client:
        tasks = [asyncio.create_task(client.get(u)) for u in urls]
        try:
            return await asyncio.gather(*tasks)
        finally:
            for t in tasks:
                t.cancel()

asyncio.run(main(urls))

How to stop it coming back

  • Treat `asyncio.run` as a `main()` construct: one per process, never inside a library function or a helper
  • Own the lifecycle of every client in a lifespan or startup/shutdown hook rather than lazily on first use
  • Keep strong references to created tasks and drain them on shutdown; a fire-and-forget task is an untracked resource
  • Run CI with `-X dev` and `PYTHONASYNCIODEBUG=1` so unclosed resources and slow callbacks fail loudly in development
  • Never silence `Exception ignored in __del__` — it is a real unclosed resource reported at the least convenient moment

Practise production debugging in a real repository

Reading about a failure and reproducing one are different skills. Gronex ships broken backend repositories with failing test suites that encode the real invariant, so you debug from evidence instead of memorising symptoms.

FAQ

Why does it only happen on the second call?

Because the first call created the loop, created your cached object on it, and closed the loop on exit. The second call creates a different loop, finds the cached object, and that object tries to use the loop it remembers. One cached object plus two `asyncio.run` calls is all it takes.

Is the version at interpreter exit safe to ignore?

No. It means a transport or pool was still alive when the loop closed, which usually also means sockets were not shut down cleanly and in-flight writes may not have completed. The cosmetic-looking message is the visible end of a resource-lifecycle bug.

Can I reopen a closed loop?

No — `close()` is terminal. `asyncio.new_event_loop()` plus `set_event_loop` gives you a fresh loop, but every object bound to the old one stays unusable, so this only helps if you also rebuild those objects. Fixing the ownership is less work than managing two generations of loops.

Why does pytest hit this constantly?

Each async test typically gets its own loop, so any fixture or module-level client that outlives a single test straddles loops. Align scopes: function-scoped clients with function-scoped loops, or session-scoped both, and never a session-scoped client over per-test loops.

Related

Other errors engineers hit next to this one

Full error and symptom index →