Debugging & Performance

Debugging "Event loop is closed" RuntimeError

RuntimeError: Event loop is closed almost always fires at the very end of a program or test, with a traceback pointing at a transport's __del__ or a connection cleanup rather than your code. It means something scheduled work on an event loop that has already been closed. The three recurring causes are reusing a loop after asyncio.run closed it, calling asyncio.run more than once, and leaving tasks or transports dangling at teardown. This guide fixes each, including the pytest-asyncio variant.

Prerequisites

  • Python 3.8+ (asyncio.run, asyncio.all_tasks, asyncio.get_running_loop).
  • For the test case: pytest-asyncio >= 0.23 (the loop_scope parameter and asyncio_default_fixture_loop_scope setting were added in 0.23; earlier versions used the event_loop fixture).

Solution

The state machine below shows where the error is raised — work entering a loop that has already transitioned to closed.

Event loop state and the error A loop moves from created to running to closed; scheduling work after close raises RuntimeError: Event loop is closed. When the error is raised created asyncio.run starts running tasks scheduled OK closed loop torn down schedule after close RuntimeError: Event loop is closed dangling task, transport, or second run
The loop created by asyncio.run is closed when it returns; any task, transport, or second asyncio.run that touches it afterwards is scheduling work against a closed loop, which raises the error.

Run a single top-level coroutine and drain everything before the loop closes:

Python
import asyncio

async def main() -> None:
    task = asyncio.create_task(worker())
    try:
        await do_work()
    finally:
        # Cancel and await stragglers so nothing is pending when run() closes
        # the loop. return_exceptions=True swallows the CancelledError each raises.
        task.cancel()
        await asyncio.gather(task, return_exceptions=True)

# ONE asyncio.run for the whole program. It creates a loop, runs main, closes it.
asyncio.run(main())

For pytest-asyncio, match loop_scope to the fixture scope so teardown runs on a live loop:

TOML
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"   # 0.23+: stop recreating the loop per test
Python
import pytest_asyncio

# scope and loop_scope agree -> setup and teardown share one loop.
@pytest_asyncio.fixture(scope="session", loop_scope="session")
async def client():
    c = await open_client()
    yield c
    await c.aclose()        # runs on the SAME loop, not a closed one

Why this works

asyncio.run is documented to create a fresh event loop, run the coroutine to completion, and then close that loop before returning. Anything still bound to it — a database connection's transport, a background Task, a __del__ cleanup — fires its callback against a loop that no longer accepts work, raising the error. Draining tasks and closing transports inside main (or a fixture's teardown) guarantees nothing is left to schedule after close. In pytest-asyncio, the loop is owned by the loop_scope; if a session fixture's teardown runs after a function-scoped loop has already closed, you hit the same wall, which is why aligning the scopes is the fix. The scope-versus-loop relationship is dissected in how to scope pytest fixtures for async tests.

Edge cases and failure modes

  • Calling asyncio.run twice. Each call closes its loop, so any object created in the first run is bound to a dead loop in the second. Use one asyncio.run and structure work as nested coroutines, or asyncio.Runner (3.11+) to reuse one loop across several run calls.
  • aiohttp / asyncpg cleanup on __del__. Connections that schedule cleanup in __del__ raise this at interpreter shutdown. Always await session.close() / await conn.close() explicitly inside the loop.
  • loop.run_until_complete after loop.close. Reusing a manually managed loop after closing it is the non-asyncio.run form of the same bug. Do not close a loop you intend to reuse.
  • ProactorEventLoop on Windows. Older Pythons raised this spuriously at shutdown on Windows even with correct code; upgrade to a current 3.x where it is fixed.
  • Cross-loop objects in pytest. An object built in a session fixture but used by a function-scoped loop straddles two loops. Match loop_scope, and see the pytest-asyncio vs anyio scoping trade-offs for choosing a model.

Frequently Asked Questions

Why does asyncio.run raise RuntimeError: Event loop is closed?asyncio.run creates a new loop, runs the coroutine, then closes that loop. Calling it twice and reusing anything bound to the first loop, or leaving tasks and transports alive when it closes, raises the error because work is scheduled on a loop that no longer exists.

How do I avoid the error with pytest-asyncio? Match the fixture's loop_scope to its scope in pytest-asyncio 0.23 or newer so setup and teardown share one loop, and set asyncio_default_fixture_loop_scope so the loop is not recreated per test.

Why does it only appear at the end of the program? It usually comes from teardown: a transport, connection, or task is still pending when the loop closes, so its cleanup callback fires against a closed loop. Cancel and await all tasks and close transports before the loop shuts down.

← Back to Debugging Async Code and Event Loops