Event Loop Is Closed: Fix asyncio Reuse After Close
Build a fresh loop per run instead of reusing a closed one.
20+ years shipping production Python across data and backend systems. Drawn from code that ran under real load.
- ✓Basic asyncio: async def, await, and running a script with
asyncio.run() - ✓Reading Python tracebacks to find the failing call
- ✓Installing third-party packages with pip (for the aiohttp discussion)
- Don't reuse the loop: asyncio.run() builds a loop, runs your coroutine, then closes it — scheduling on it again raises RuntimeError.
- Fix it now: call asyncio.run(main()) once per entry point, or make a fresh loop with asyncio.new_event_loop() if you manage loops by hand.
- Close aiohttp first: await session.close() inside the coroutine before asyncio.run() returns, so connector cleanup runs on the live loop.
- In libraries never call asyncio.get_event_loop(): use asyncio.get_running_loop() in coroutines or take a loop parameter.
Think of the event loop as a pop-up food truck. asyncio.run() drives the truck in, serves your order, then drives away and locks up for the night. Your code is the customer holding yesterday's ticket and banging on the shutter. The fix isn't to bang harder — it's to call the truck back with a fresh asyncio.run() or a new loop, and to finish all your orders (like closing network sessions) before it leaves.
You wrote an async script, it ran fine once, then you refactored it and now every run ends with RuntimeError: Event loop is closed. Nothing in the traceback points at your business logic. The failure sits one layer below it, in the machinery that drives your coroutines.
The confusion is understandable because the loop is invisible most of the time. asyncio.run() hides it: it builds a loop, runs your coroutine, then tears the loop down. That teardown is permanent. A closed loop never reopens, yet a surprising amount of code keeps a reference to it — a cached loop in a global, an aiohttp session bound to the old loop, a test fixture shared across cases — and tries to schedule one more callback on it.
This article traces each trigger to its fix. You'll see why asyncio.run() closes its loop by design, how reuse-after-close actually happens in real code, the exact order for shutting down aiohttp sessions, what the Windows Proactor warning means, and why libraries must stop calling get_event_loop(). By the end you'll have a small set of patterns — one loop per run, cleanup before close, explicit loop passing — that make this error disappear for good.
asyncio.run() Closes Its Loop: Why the Second Call Fails
asyncio.run() is a three-act play: it creates a new event loop, runs your coroutine to completion on it, then shuts down async generators, cancels leftovers, and closes the loop. Closing is a one-way door. The loop releases its selector and file descriptors and flips an internal flag, and every scheduling method starts with a check that raises RuntimeError: Event loop is closed when that flag is set. There is no reopen, no reset, no revive.
That finality surprises people because the loop object still exists. Your variable still points at it, its methods still autocomplete, and the error only appears when you actually schedule something — run_until_complete, create_task, or call_soon. So code that cached the loop keeps working right up until the first reuse, which can be minutes later in a worker or the second test in a suite.
The rule is simple: one asyncio.run() call owns exactly one loop for exactly one run. Sequential jobs mean sequential asyncio.run() calls, each with its own fresh loop. If you manage loops by hand with new_event_loop(), construct the loop where you use it and close it when you're done. Never stash a loop in a module global, a default argument, or a singleton and expect it to survive across runs. The demo below shows the failure and the fix side by side.
Reusing Loops and Clients After Close: the Real Trigger
Reuse-after-close rarely looks like reuse. It looks like a helper that takes a loop parameter defaulting to a stale global, a class that stores self.loop at construction and schedules tasks in a later method, or a test fixture with module scope shared across cases. The first user of the cached loop closes it — or asyncio.run() closes its own loop while the cached reference still points at it — and the next user inherits a corpse.
The tell is timing: the first run works and later ones fail, or tests pass in isolation and fail as a suite. When you see that pattern, hunt for stored loops. Search for get_event_loop, new_event_loop, and loop parameters kept beyond a single call. A loop should live exactly as long as the run it serves, constructed at the top of the entry point and closed in a finally block.
When you must own the loop yourself — embedding asyncio in a sync codebase, for example — wrap construction, use, and teardown in one helper like the runner below. It builds the loop, sets it as current, runs the coroutine, shuts down async generators, closes the loop, and clears the thread-local slot. Every call gets a virgin loop, so close-races between sequential runs become impossible by construction.
aiohttp Cleanup Order: Close Sessions Before the Loop Dies
aiohttp sessions are loop-bound. The ClientSession's connector schedules DNS refreshes, connection reaps, and cleanup callbacks on the running loop at creation time. If you create the session at import time or in a prior run, those callbacks belong to a loop that asyncio.run() has since closed. The next request — or even garbage collection of the old connector — tries to schedule on the dead loop and raises.
The fix is ownership discipline: create the session inside the coroutine that asyncio.run() executes, and await session.close() in a finally block before that coroutine returns. The async with ClientSession() pattern does this automatically. Closing inside the run guarantees the connector's teardown callbacks execute on the still-live loop, leaving nothing dangling for the garbage collector to trip over.
Order matters beyond sessions too. Cancel or await background tasks first, then close sessions and connection pools, then let asyncio.run() close the loop. Reversing that order — loop first, sessions second — is the classic source of unclosed-connector warnings paired with loop-closed errors. The snippet below models the safe shape with a stand-in session so you can run it without installing anything.
Windows Proactor Warning: a Different Loop, Same Error
Windows runs asyncio on the Proactor event loop by default, while Linux and macOS use selector-based loops. The Proactor model doesn't implement selector APIs like add_reader() and add_writer(), so libraries written against selectors emit warnings or fail outright on Windows. You'll see loop-closed errors nearby when fallback code constructs and discards loops while probing for a working policy.
The pragmatic fix is to set the policy once at startup, and only on Windows. Guard it with a sys.platform check so POSIX behavior stays untouched: on win32, install asyncio.WindowsSelectorEventLoopPolicy() before any loop is created. That gives subprocess and selector support back at the cost of Proactor-specific features like overlapped I/O, which most HTTP client code doesn't need.
Better yet, write policy-agnostic code so the platform default stops mattering. Build loops with asyncio.new_event_loop(), avoid add_reader/add_writer in application code, and prefer high-level APIs like asyncio.subprocess and streams. The helper below runs anywhere without touching policy internals, and it keeps working whether the default underneath is Proactor or selector. That portability also simplifies onboarding: new hires run the same entry point on any laptop without platform-specific setup docs.
Never Call get_event_loop() in Libraries: Pass Loops Explicitly
asyncio.get_event_loop() is a trap in shared code. At module scope or in a sync helper it returns the thread-local current loop — or creates one with deprecation warnings on newer Pythons — which may be closed, foreign to the caller's thread, or simply not the loop that's actually running the caller's coroutine. Libraries that stash that value bake one context's loop into every future call.
The replacement depends on context. Inside a coroutine, asyncio.get_running_loop() always returns the loop that's executing you — no guessing, no thread-local roulette. In sync code that must bridge into async, accept an explicit loop parameter or, cleaner still, expose the coroutine and let the caller drive it with asyncio.run(). Both shapes keep loop ownership with the entry point instead of the library.
This also kills a whole class of test bugs. Suites that call helpers across cases with different loops stop flaking once helpers quit caching. Lint for get_event_loop in library paths and rewrite each hit as get_running_loop or an injected parameter. The snippet shows the pattern: the helper reads the running loop at call time, so it works under any runner, in any thread, on any run — no hidden coupling to whoever created a loop first.
get_event_loop() at import and cached it. Apps embedding the SDK in workers crashed after the first loop restart. Switching the SDK to get_running_loop() closed a year of intermittent tickets.One Loop Per Thread: the Pattern That Ends the Error
Event loops are not thread-safe and not shareable. Each thread that runs asyncio needs its own loop, created in that thread and closed in that thread. Sharing one loop across threads means callbacks fire on the wrong thread, and closing it in one thread pulls the rug out from tasks running in another — surfacing as loop-closed errors far from the actual close call.
The clean shape is one asyncio.run() per thread, as the snippet shows. Each thread entry point calls asyncio.run() with its own coroutine, so loop construction, execution, and teardown all happen on the owning thread with zero sharing. Results flow back through thread-safe channels like queues or pre-sized dicts written before join.
Mind the lifecycle edges. Join every async thread before process exit or the interpreter may tear down loops mid-callback. Keep sessions and clients inside the thread's coroutine so cleanup runs on the owning loop. And never call set_event_loop with a loop from another thread — the thread-local slot exists precisely to keep each thread's loop private. Follow those rules and threaded asyncio stays boring, which is exactly what you want. The pattern scales cleanly too: adding a ninth or tenth worker means spawning one more thread with its own run, not re-architecting shared state.
asyncio.run() ended the flakiness in a day.The Shared aiohttp Session That Died on the Second Batch
asyncio.run() built a fresh loop, the old session's connector tried to schedule cleanup on the closed first loop, raising RuntimeError: Event loop is closed. The retry wrapper then retried the failing batch three times, multiplying doomed requests against the provider.asyncio.run() returned. A regression test ran two batches in one process and asserted zero warnings. The team also banned module-level sessions in the style guide.- asyncio.run() owns its loop end to end — anything bound to that loop must be created and destroyed inside the same run.
- Module-level async resources are shared-loop landmines. Construct sessions, connections, and clients inside the coroutine that uses them.
- One-batch staging runs can't catch reuse bugs. Regression tests must execute the entry point twice in one process.
PYTHONASYNCIODEBUG=1 python -W error::RuntimeWarning app.py. Debug mode logs slow callbacks and unclosed resources, and the warning filter turns the silent loop misuse into a loud traceback at the exact line.print('closed:', loop.is_closed()) right before the failing call, or run python -c "import asyncio; l=asyncio.new_event_loop(); l.close(); print(l.is_closed())" to confirm the state. If it prints True, you're scheduling on a dead loop — build a fresh one.python -W default::RuntimeWarning app.py 2>&1 | grep -i 'unclosed\|destroyed\|pending'. Unclosed-connector or destroyed-task lines mean cleanup ran after close — move session.close() inside the coroutine.python -c "import sys, asyncio; print(sys.platform, type(asyncio.get_event_loop_policy()).__name__)" on the failing machine. A Proactor policy on win32 with selector warnings means you need WindowsSelectorEventLoopPolicy at startup.grep -rn 'get_event_loop\|_loop =' --include='*.py' src/ | head -30. Cached loops in globals or default args are the reuse vector — replace them with get_running_loop() inside coroutines.| File | Command / Code | Purpose |
|---|---|---|
| loop_reuse_demo.py | async def fetch(n): | asyncio.run() Closes Its Loop |
| fresh_loop_runner.py | async def work(tag): | Reusing Loops and Clients After Close |
| session_cleanup_order.py | class FakeSession: | aiohttp Cleanup Order |
| portable_loop_run.py | print("platform:", sys.platform) | Windows Proactor Warning |
| library_loop_pattern.py | async def library_call(url, loop=None): | Never Call get_event_loop() in Libraries |
| loop_per_thread.py | async def job(n): | One Loop Per Thread |
Key takeaways
asyncio.run() call; sequential jobs mean sequential calls, each with a fresh loop.get_running_loop() inside coroutines, never get_event_loop() at module scope.Common mistakes to avoid
5 patternsCalling asyncio.run() inside a function that is already running in a loop
asyncio.run() shuts down the shared loop.main()) exactly once per entry point. If a helper already runs the loop, return the coroutine instead of calling asyncio.run() again inside it.Creating an aiohttp ClientSession at import time and reusing it across runs
asyncio.run() executes, and await session.close() in a finally block before main() returns.Caching a loop object in a global and reusing it after close
asyncio.get_running_loop() inside coroutines. Never cache a loop object in a global or a default argument.Assuming the default loop policy is identical on Linux and Windows
asyncio.new_event_loop().Closing the loop while tasks or connectors are still pending
asyncio.run() close the loop.Interview Questions on This Topic
What does asyncio.run() do with the event loop, and why can't you reuse it?
asyncio.run() creates a fresh loop, runs your main coroutine to completion, cancels leftover tasks, shuts down async generators, and closes the loop. Closing is permanent, so any later scheduling on that loop raises RuntimeError.Frequently Asked Questions
20+ years shipping production Python across data and backend systems. Drawn from code that ran under real load.
That's Errors. Mark it forged?
5 min read · try the examples if you haven't