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.
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 loopThe 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 happens | Family A Event loop is closed | Family B already running / asyncio.run() cannot be called... | Family C attached to a different loop |
|---|---|---|---|
| Jupyter / IPython | You ran asyncio.run() in a cell more than once; the previous loop is closed and you reuse objects bound to it | Classic. ipykernel is already running a loop, so asyncio.run() itself is rejected | A session created in cell A is reused on a different kernel loop |
| pytest-asyncio | The next test uses a session bound to a function-scoped loop that was closed when the previous test ended | Calling asyncio.run() inside a sync test function, plus a missing asyncio_mode setting | Classic. session-scoped fixture vs. function-scoped loop mismatch |
asyncio.run() inside a FastAPI route | — | Classic. uvicorn is already driving a loop | asyncio.run() creates a new loop and then touches the app-global connection pool |
| Module-global aiohttp/httpx session | Classic. A session bound at import time is called after that loop has closed | — | Classic. Each worker/test gets a different loop, so create-loop ≠ use-loop |
| Windows ProactorEventLoop | Classic. 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:
| Family | One-line check | Correct pattern |
|---|---|---|
| A | python -c "import sys; print(sys.platform, sys.version)" + whether __del__ appears in the traceback | Fixes by family and version pitfalls — manage session lifetime; await session.close() on shutdown |
| B | Run the where_am_i() snippet below → running loop: <...> | Remove asyncio.run() and await, or consult the nest_asyncio decision table |
| C | Snippet that compares id(loop) at create vs. use | Align 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.
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:
# (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__ noiseIf 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.
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).
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 getEvent 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.
- Exactly once, at the application entry point:
asyncio.run(main()). Once per process. - 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, useasyncio.run_coroutine_threadsafe(coro, loop)on a separate thread. - Library code never creates a loop. Libraries expose coroutines only; loop create/teardown belongs to the caller. Leave
loop.run_until_completeonly in legacy code that already owns and manages a loop object explicitly.
Behavioral differences in Python 3.10 / 3.11 / 3.12
| Item | 3.10 | 3.11 | 3.12 |
|---|---|---|---|
asyncio.get_event_loop() (called outside a loop) | Creates a loop if none exists; little/no warning | Deprecation in progress | Raises DeprecationWarning; do not rely on auto-create when there is no current loop |
| Auto-create a loop when none is running | Generally works | Being narrowed | Moving toward removal — code that depends on it can break |
asyncio.Runner | None | Introduced | Available |
TaskGroup / asyncio.timeout() | None | Introduced | Available |
| Recommended entry point | asyncio.run() | asyncio.run() or Runner | asyncio.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.
import asyncio
async def main():
...
with asyncio.Runner() as runner: # Python 3.11+
runner.run(main())
runner.run(main()) # reuses the same loopCalling 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.
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"; if you use pytest.ini
[pytest]
asyncio_mode = auto-
Symptom: you redefined the
event_loopfixture and now get a DeprecationWarning → As of the 0.23+ line, redefining theevent_loopfixture 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.
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 NoneThe 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.
pip show pytest-asyncio | head -3nest_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
# 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
# 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.
@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
| Workaround | Why it looks like it works | Actual side effects |
|---|---|---|
① Blind nest_asyncio.apply() | Error message vanishes immediately | Loop 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 while | Sessions, 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 request | Logs go quiet | Connections 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:
- Inside a loop or outside? — Decide with
where_am_i().INSIDEplusasyncio.run()is family B; replace withawaiton the spot. - Compare
id(loop)— Different create vs. use → family C. Same id but already closed → family A. - 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.
Nodelog는 모든 콘텐츠의 내용과 출처를 공개 전에 검토합니다. 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서와 함께 확인하며, 검토 기준과 정정 원칙은 편집 정책에서 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.
Comments
Be the first to comment.