/개발/RuntimeError: Event loop is closed — Causes and a 30-Second Diagnosis
Developmentpython asyncioEvent loop is closed

RuntimeError: Event loop is closed — Causes and a 30-Second Diagnosis

A 30-second runbook for telling apart the Event loop is closed, already running, and attached to a different loop families. Covers Jupyter, pytest-asyncio, and FastAPI causes, lifespan and fixture-scope alignment, and when nest_asyncio is a

RuntimeError: Event loop is closed — Causes and a 30-Second Diagnosis

Fine locally, but it only blows up in Jupyter, pytest, or production

Parts 1–5 of this Python development guide covered issues that blow up before the code even runs — PEP 668 externally managed environments, venv/Poetry dependency collisions, and a ModuleNotFoundError runbook. Even when the environment is perfectly aligned, one family still blows up after the code starts: asyncio event-loop errors.

These four lines are the usual suspects.

TEXT
RuntimeError: Event loop is closed
RuntimeError: This event loop is already running
RuntimeError: asyncio.run() cannot be called from a running event loop
RuntimeError: Task <Task pending ...> got Future <Future pending> attached to a different loop

The common cause of all of them fits in one sentence: there is not just one loop. A Python process can create and destroy multiple event loops, and objects such as aiohttp.ClientSession, asyncio.Lock, asyncio.Queue, and DB connection pools are bound to the loop they were created on. The moment the loop they were created on differs from the loop they are used on, one of those four lines appears.

Debugging is therefore exactly three steps. ① Decide whether the current code is inside or outside a loop → ② Compare id(loop) at object-creation time vs. use time → ③ Move loop ownership onto lifespan or a fixture. If a search for python asyncio 에러 해결 brought you here, start with the diagnosis table below.

This article applies to CPython 3.10–3.13, FastAPI 0.100+ / Starlette, aiohttp 3.9+, httpx 0.27+, and pytest-asyncio 0.21–0.24.

Error text → cause mapping table (30-second step 1)

Cross the raw error message with where it blew up and the family becomes clear.

Where it happensFamily A Event loop is closedFamily B already running / asyncio.run() cannot be called...Family C attached to a different loop
Jupyter / IPythonYou ran asyncio.run() in a cell more than once; the previous loop is closed and you reuse objects bound to itClassic. ipykernel is already running a loop, so asyncio.run() itself is rejectedA session created in cell A is reused on a different kernel loop
pytest-asyncioThe next test uses a session bound to a function-scoped loop that was closed when the previous test endedCalling asyncio.run() inside a sync test function, plus a missing asyncio_mode settingClassic. session-scoped fixture vs. function-scoped loop mismatch
asyncio.run() inside a FastAPI routeClassic. uvicorn is already driving a loopasyncio.run() creates a new loop and then touches the app-global connection pool
Module-global aiohttp/httpx sessionClassic. A session bound at import time is called after that loop has closedClassic. Each worker/test gets a different loop, so create-loop ≠ use-loop
Windows ProactorEventLoopClassic. Interpreter shutdown: a transport __del__ touches a closed loop (_ProactorBasePipeTransport.__del__ in the stack)Occurs when each thread is given a different loop

One-line checks and the next action:

FamilyOne-line checkCorrect pattern
Apython -c "import sys; print(sys.platform, sys.version)" + whether __del__ appears in the tracebackFixes by family and version pitfalls — manage session lifetime; await session.close() on shutdown
BRun the where_am_i() snippet below → running loop: <...>Remove asyncio.run() and await, or consult the nest_asyncio decision table
CSnippet that compares id(loop) at create vs. useAlign fixture loop_scope / inject via lifespan

30-second diagnosis: which loop is this code running on?

Diagnosis 1 — inside a loop or outside?

Paste this function immediately above the failing site and call it.

Python
import asyncio, sys

def where_am_i(tag: str = "") -> None:
    print(f"--- where_am_i {tag} ---")
    print("python :", sys.version.split()[0], "|", sys.platform)
    try:
        loop = asyncio.get_running_loop()
        print("state  : INSIDE running loop")
        print("loop   :", type(loop).__name__, "id=", id(loop))
    except RuntimeError:
        print("state  : OUTSIDE (no running loop)")
    print("policy :", type(asyncio.get_event_loop_policy()).__name__)

Expected output and the branch:

TEXT
# (1) Ordinary script, before asyncio.run()
state  : OUTSIDE (no running loop)
# → asyncio.run(main()) is correct. Not family B.

# (2) Inside a Jupyter cell / FastAPI route handler
state  : INSIDE running loop
loop   : _UnixSelectorEventLoop id=140234...
# → Calling asyncio.run() here is 100% family B. Switch to await.

# (3) On Windows
python : 3.12.4 | win32
policy : WindowsProactorEventLoopPolicy
# → Check for family-A __del__ noise

If it prints INSIDE and the code still has asyncio.run(...), diagnosis is over. It is family B, and the fix is await, not nest_asyncio.

Diagnosis 2 — compare create-loop id vs. use-loop id

This comparison splits family C from family A. Plant it at both the place that creates the session and the place that uses it.

Python
import asyncio, httpx

class TracedClient(httpx.AsyncClient):
    def __init__(self, *a, **kw):
        super().__init__(*a, **kw)
        try:
            self.born_loop = id(asyncio.get_running_loop())
        except RuntimeError:
            self.born_loop = None  # created outside a loop = danger signal
        print("[create] born_loop =", self.born_loop)

    async def request(self, *a, **kw):
        now = id(asyncio.get_running_loop())
        if now != self.born_loop:
            print(f"[MISMATCH] born={self.born_loop} now={now}")
        return await super().request(*a, **kw)

With aiohttp you can compare an internal attribute directly (private, so diagnosis-only).

Python
print("session loop:", id(session._loop))
print("running loop:", id(asyncio.get_running_loop()))

Decision criteria:

  • born_loop is None → created at module-global / import time. Family A candidate. The first request may succeed, but once the loop closes you get Event loop is closed.
  • [MISMATCH] printed → family C confirmed. Ownership lives in the wrong place.
  • Same id, but still Event loop is closed → the loop is already closed and a __del__ / background task is touching it. Family A shutdown-order problem.

Fixes by family and version pitfalls

When to use asyncio.run vs run_until_complete

Keep these three rules and family B almost disappears.

  1. Exactly once, at the application entry point: asyncio.run(main()). Once per process.
  2. In a host that already runs a loop (Jupyter, uvicorn, some Celery workers, GUI frameworks), do not create a new loop. Absorb with await, or if you truly must call from a sync function, use asyncio.run_coroutine_threadsafe(coro, loop) on a separate thread.
  3. Library code never creates a loop. Libraries expose coroutines only; loop create/teardown belongs to the caller. Leave loop.run_until_complete only in legacy code that already owns and manages a loop object explicitly.

Behavioral differences in Python 3.10 / 3.11 / 3.12

Item3.103.113.12
asyncio.get_event_loop() (called outside a loop)Creates a loop if none exists; little/no warningDeprecation in progressRaises DeprecationWarning; do not rely on auto-create when there is no current loop
Auto-create a loop when none is runningGenerally worksBeing narrowedMoving toward removal — code that depends on it can break
asyncio.RunnerNoneIntroducedAvailable
TaskGroup / asyncio.timeout()NoneIntroducedAvailable
Recommended entry pointasyncio.run()asyncio.run() or Runnerasyncio.run() / Runner

For the exact per-version wording, check CPython’s official asyncio docs and each release’s “What’s New”. The practical takeaway is simple: removing asyncio.get_event_loop() from your code is 90% of a 3.12+ migration. Inside a loop use asyncio.get_running_loop(); outside a loop use asyncio.run().

If you also need to control loop policy on 3.11+, Runner is the clean option.

Python
import asyncio

async def main():
    ...

with asyncio.Runner() as runner:   # Python 3.11+
    runner.run(main())
    runner.run(main())             # reuses the same loop

Calling asyncio.run() twice creates two loops and closes the first. Using a session bound to the first loop in the second call is exactly family A. Runner structurally prevents that.

pytest-asyncio failure branches

pytest-asyncio is confusing because config location and fixture rules change across versions. Split by symptom.

  • Symptom: coroutine tests are skipped, or "async def functions are not natively supported" → Missing mode setting.
TOML
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
INI
; if you use pytest.ini
[pytest]
asyncio_mode = auto
  • Symptom: you redefined the event_loop fixture and now get a DeprecationWarning → As of the 0.23+ line, redefining the event_loop fixture is not recommended. Align loop lifetime with scope options, not by overriding the fixture.

  • Symptom: Task ... attached to a different loop (family C) → A session-scoped fixture’s object is being used on a function-scoped loop. Align the scopes.

Python
import pytest, pytest_asyncio, httpx

@pytest_asyncio.fixture(loop_scope="session", scope="session")
async def client():
    async with httpx.AsyncClient(base_url="http://test") as c:
        yield c

@pytest.mark.asyncio(loop_scope="session")
async def test_ping(client):
    assert client is not None

The key is matching the fixture’s scope and the loop’s loop_scope to the same value. A session-scoped fixture with a fresh loop per function will break from the second test onward. Unifying everything on function scope also works (it is just slower). pytest-asyncio has renamed options across minor versions, so pin the version and confirm option names against the installed version’s README/docs.

Bash
pip show pytest-asyncio | head -3

nest_asyncio decision checklist

nest_asyncio patches the loop so asyncio.run() is forcibly allowed inside an already-running loop. Convenient, with side effects.

OK to use

  • One-off exploration / demo code in Jupyter/IPython
  • Reversible local scripts and short-lived batches

Do not use

  • Production servers (FastAPI/uvicorn, etc.) — loop reentrancy muddies task cancellation, timeouts, and exception propagation
  • Environments using uvloop — the patch assumes the stdlib loop implementation and is known not to be compatible
  • Libraries you ship — silently patching the user’s loop is an obvious nuisance
  • Code that manages lifecycle (connection pools, background tasks, …)

The decision is one sentence: “Could this code run in someone else’s process?” If yes, don’t use it.

Recurrence-prevention patterns and three wrong fixes

Before — global singleton session antipattern

Python
# app/clients.py  ❌
import httpx

client = httpx.AsyncClient(timeout=10.0)  # import time = outside a loop

async def fetch(url: str):
    return await client.get(url)

There is no running loop at import time. This client binds to the first loop that uses it, then collapses with Event loop is closed or attached to a different loop when tests, multiple workers, or restarts change the loop.

After — own it in FastAPI lifespan

Python
# app/main.py  ✅
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
import httpx

@asynccontextmanager
async def lifespan(app: FastAPI):
    # startup: create inside the running loop
    app.state.http = httpx.AsyncClient(timeout=10.0)
    try:
        yield
    finally:
        # shutdown: clean up before the loop closes
        await app.state.http.aclose()

app = FastAPI(lifespan=lifespan)

Handlers inject via the request object.

Python
@app.get("/proxy")
async def proxy(request: Request):
    client: httpx.AsyncClient = request.app.state.http
    r = await client.get("https://example.com")
    return {"status": r.status_code}

This structure buys you three things. ① Creation happens inside the running loop, so the born_loop is None risk disappears. ② Teardown is guaranteed before the loop closes, so family-A __del__ noise drops. ③ Tests can swap the entire lifespan, which structurally blocks family C. Create and close DB pools, Redis clients, and background tasks in the same place. The current recommendation is to migrate legacy @app.on_event("startup") to lifespan.

Three wrong fixes

WorkaroundWhy it looks like it worksActual side effects
① Blind nest_asyncio.apply()Error message vanishes immediatelyLoop reentrancy breaks cancellation/timeout semantics; incompatible with uvloop/anyio stacks. Can become an untraceable deadlock in production
② Overwrite with asyncio.set_event_loop(asyncio.new_event_loop())It runs on the new loop, for a whileSessions, locks, and queues bound to the old loop all become orphans. Mass-produces family C; unclosed loops accumulate and leak fds
try/except RuntimeError: pass, or a new loop per requestLogs go quietConnections pile up without cleanup. A loop per request throws away pooling and keep-alive, causing latency and socket exhaustion

The answer is not hiding the error; it is moving ownership.

FAQ

Q1. In Jupyter I get asyncio.run() cannot be called from a running event loop. What now? Because ipykernel is already running a loop. In current IPython/Jupyter you can await coro() directly in a cell, so deleting asyncio.run() and leaving await is the first move. If you still need to call from a sync function for throwaway exploration, consider nest_asyncio for that case only — and strip it before the same code goes to production.

Q2. On Windows the program exits cleanly, but I still see RuntimeError: Event loop is closed. If the traceback shows a destructor frame such as _ProactorBasePipeTransport.__del__, it is shutdown noise: a transport cleaning up after the loop has already closed. It often does not affect real logic. The real fix is to explicitly await session.close() (aiohttp) or await client.aclose() (httpx) before exit, and cancel/await leftover tasks.

Q3. Should I stop using asyncio.get_event_loop()? If you need the current loop from inside a loop, use asyncio.get_running_loop(). If you need to run a coroutine from outside a loop, use asyncio.run() (or 3.11+ asyncio.Runner). get_event_loop() is narrowing, with warnings, on the 3.12 line — avoid it in new code. Confirm exact per-version wording in the official CPython asyncio docs.

Conclusion: a 3-line runbook

To recap:

  1. Inside a loop or outside? — Decide with where_am_i(). INSIDE plus asyncio.run() is family B; replace with await on the spot.
  2. Compare id(loop) — Different create vs. use → family C. Same id but already closed → family A.
  3. Move ownership — Create and close sessions, pools, and locks in lifespan (production) or a scope-aligned fixture (tests). No global import-time construction.

Keep those three lines and most of RuntimeError: Event loop is closed, This event loop is already running, and Task attached to a different loop will not come back. If you need to drop back to install-time problems, see this series’ PEP 668 piece, the venv–Poetry dependency-collision piece, and the ModuleNotFoundError runbook. Separating environment issues from runtime loop issues by itself cuts a lot of debug time.

Part 7 goes one level deeper: when async code is “error-free but slow” — detecting blocking calls that stall the event loop, and peeling them off with run_in_executor and anyio.to_thread.

확인 정보
✦ ✦ ✦
편집 검토 · Editorial Review

Nodelog는 모든 콘텐츠의 내용과 출처를 공개 전에 검토합니다. 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서와 함께 확인하며, 검토 기준과 정정 원칙은 편집 정책에서 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.

편집 책임 · Nodelog 기술 편집팀·발행 · ·업데이트 ·

Comments

Be the first to comment.