RuntimeError: Event loop is closed (asyncio, tested)

RuntimeError: Event loop is closed almost always means an object created under one event loop is being used or closed after that loop shut down. We reproduced it with a plain asyncio client and a local server, measured the two different errors it produces, and tested the fix.

1. The plain version: reusing a closed loop

import asyncio

async def main():
    return "done"

loop = asyncio.new_event_loop()
print(loop.run_until_complete(main()))
loop.close()

coro = main()
try:
    loop.run_until_complete(coro)
except RuntimeError as e:
    coro.close()
    print(repr(e))
done
RuntimeError('Event loop is closed')

Measured: once loop.close() has run, anything that schedules work on that loop raises RuntimeError: Event loop is closed. asyncio.run() creates a new loop and closes it when it returns, so the same thing happens to anything that kept a reference to the loop from an earlier asyncio.run().

2. The real-world version: a client that outlives asyncio.run()

The usual cause in practice: a connection, session or client object (an HTTP session, a DB pool, a stream) is created during one asyncio.run() call and used or closed during a later one. We reproduced it with plain asyncio streams against a local echo server (the helper in server.py runs asyncio.start_server in a background thread):

import asyncio
from server import start_echo_server   # local echo server on port 8963

class Client:
    def __init__(self):
        self.writer = None

    async def ping(self):
        if self.writer is None:   # connect lazily, then reuse
            self.reader, self.writer = await asyncio.open_connection("127.0.0.1", 8963)
        self.writer.write(b"ping\n")
        await self.writer.drain()
        return await self.reader.readline()

    async def close(self):
        self.writer.close()

start_echo_server()
client = Client()
print(asyncio.run(client.ping()))          # first loop: works

try:
    asyncio.run(client.ping())             # second loop: stale connection
except RuntimeError as e:
    print("ping: ...", str(e)[-30:])

try:
    asyncio.run(client.close())
except RuntimeError as e:
    print("close:", repr(e))
b'ping\n'
ping: ... > attached to a different loop
close: RuntimeError('Event loop is closed')

Measured: the first call works. The second asyncio.run() starts a new loop, but the connection still belongs to the first, now closed, loop. Reading from it raised RuntimeError: ... attached to a different loop, and closing it raised RuntimeError: Event loop is closed, because close() schedules the transport's cleanup on the loop it was created on. Same root cause, two different messages, depending on which method you call first. This is the pattern behind the error in scripts that call asyncio.run() once per request, in test suites where each test gets a fresh loop but a fixture is shared, and in sync wrappers around async libraries.

3. Fix: one loop, and the client lives inside it

import asyncio
from server import start_echo_server

class Client:
    async def __aenter__(self):
        self.reader, self.writer = await asyncio.open_connection("127.0.0.1", 8963)
        return self

    async def __aexit__(self, *exc):
        self.writer.close()
        await self.writer.wait_closed()

    async def ping(self):
        self.writer.write(b"ping\n")
        await self.writer.drain()
        return await self.reader.readline()

async def main():
    async with Client() as client:      # created, used and closed in ONE loop
        print(await client.ping())
        print(await client.ping())

start_echo_server()
asyncio.run(main())
print("clean exit")
b'ping\n'
b'ping\n'
clean exit

Measured: create the client inside the coroutine that asyncio.run() runs, use it there, and close it there (async with makes the close automatic). Call asyncio.run() once, at the top of the program, and await everything below it. If you need to call async code from synchronous code repeatedly, keep one loop alive for the whole program (asyncio.Runner on Python 3.11+, used as with asyncio.Runner() as r: r.run(...)) rather than a new asyncio.run() per call.

4. The opposite error: a loop that's already running

import asyncio

async def work():
    return 42

async def handler():
    coro = work()
    try:
        asyncio.run(coro)       # already inside a running loop
    except RuntimeError as e:
        coro.close()
        print(repr(e))
    print(await work())         # the fix: just await it

asyncio.run(handler())
RuntimeError('asyncio.run() cannot be called from a running event loop')
42

Measured: calling asyncio.run() from code that's already inside a loop (an async handler, a Jupyter cell, which runs its own loop) raises asyncio.run() cannot be called from a running event loop. Just await the coroutine instead (Jupyter supports top-level await).

5. The Windows-only “Exception ignored” variant

Many reports of this error are a traceback printed at exit on Windows, Exception ignored in: ... _ProactorBasePipeTransport.__del__ ... RuntimeError: Event loop is closed, after a script that otherwise worked. We tested on Linux only and could not reproduce it there: dropping an unclosed stream after asyncio.run() returned printed nothing. The cause reported for it is the same as section 2, a transport that was never closed being garbage-collected after its loop was closed, so the fix in section 3 (close what you open, inside the loop) is the one to try first.

Frequently asked questions

What causes RuntimeError: Event loop is closed?

Something tried to schedule work on a loop that has already been closed. We measured it from loop.run_until_complete() after loop.close(), and from closing a connection in a second asyncio.run() call when it was opened in the first.

Why does a second asyncio.run() call break my client?

asyncio.run() closes its loop when it returns. We measured that a connection created in the first call then raised 'attached to a different loop' when read and 'Event loop is closed' when closed in the second call.

How do I fix Event loop is closed?

Create, use and close the client inside one coroutine and call asyncio.run() once. We measured that pattern with async with: two requests and a clean exit. For repeated sync-to-async calls, keep one loop with asyncio.Runner (Python 3.11+).

What does 'asyncio.run() cannot be called from a running event loop' mean?

You're already inside a loop, for example in an async function or a Jupyter cell. We measured the error and confirmed that awaiting the coroutine directly works.

Why do I only see Event loop is closed on Windows at exit?

That 'Exception ignored in _ProactorBasePipeTransport.__del__' traceback is Windows-specific; we could not reproduce it on Linux. It is reported to come from transports that were never closed before the loop closed, so close them inside the loop.

Written with AI assistance. Measured on 2026-10-09 with Python 3.13.5 on Linux: loop.run_until_complete() after loop.close(); an asyncio streams client against a local echo server, created in one asyncio.run() and used and closed in a second; the async-with fix; and asyncio.run() inside a running loop. Every code block was run verbatim and its output pasted from that run. The Windows ProactorEventLoop variant was not tested. PlainHeaders is our extension; asyncio is built in.