Home › Python › Event Loop Is Closed: Fix asyncio Reuse After Close
Intermediate 5 min · September 23, 2026

Event Loop Is Closed: Fix asyncio Reuse After Close

Build a fresh loop per run instead of reusing a closed one.

N
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Drawn from code that ran under real load.

Follow
✓ Production
production tested
September 24, 2026
last updated
1,942
articles · all by Naren
Before you start⏱ 12 min
  • ✓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)
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • 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.
✦ Definition~90s read
What is Python Event Loop Closed Fix?

The event loop is the engine at the heart of asyncio: a single-threaded scheduler that runs coroutines, fires timers, and shuffles bytes through non-blocking sockets. When you await something, you park your coroutine on the loop and ask to be woken when the result is ready. asyncio.run() is the standard way to start that engine.

★
Think of the event loop as a pop-up food truck.

It builds a loop, sets it as current, drives your main() coroutine to completion, then unwinds everything: it cancels stray tasks, shuts down asynchronous generators, and closes the loop, releasing selectors and file descriptors back to the OS.

Closing is what makes the loop safe to abandon but impossible to reuse. A closed loop's selector is gone and its internal flag is set, so every entry point — run_until_complete(), create_task(), call_soon() — checks that flag first and raises RuntimeError. That strictness is deliberate: it turns use-after-close scheduling into a loud error instead of silent corruption.

The error therefore means one thing: some code scheduled work on a loop whose life already ended. The usual carriers are cached loop references, aiohttp sessions bound to a previous run's loop, or cleanup callbacks that fire after close. Fix the ownership — a fresh loop per run, sessions closed inside the run — and the error has nowhere to come from.

Plain-English First

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.

loop_reuse_demo.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import asyncio

async def fetch(n):
    await asyncio.sleep(0.01)
    return n * 2

# Correct: one asyncio.run() per entry point, fresh loop each time
print(asyncio.run(fetch(21)))

# The bug: keeping a loop reference and reusing it after close
loop = asyncio.new_event_loop()
print(loop.run_until_complete(fetch(10)))
loop.close()
print("closed:", loop.is_closed())
try:
    loop.run_until_complete(fetch(1))
except RuntimeError as exc:
    print("RuntimeError:", exc)

# Fix: build a new loop instead of reviving the dead one
loop2 = asyncio.new_event_loop()
try:
    print(loop2.run_until_complete(fetch(3)))
finally:
    loop2.close()
📊 Production Insight
A cron ETL job cached its loop in a global to save microseconds of setup. The first nightly batch passed and every later batch crashed, paging the team at 3 AM. Moving loop construction inside the per-batch function ended the pages.
🎯 Key Takeaway
asyncio.run() closes its loop permanently — schedule nothing on it again; start a fresh run instead.

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.

fresh_loop_runner.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import asyncio

async def work(tag):
    await asyncio.sleep(0.01)
    return f"done-{tag}"

def run_coro(coro):
    loop = asyncio.new_event_loop()
    try:
        asyncio.set_event_loop(loop)
        return loop.run_until_complete(coro)
    finally:
        try:
            loop.run_until_complete(loop.shutdown_asyncgens())
        except RuntimeError:
            pass
        loop.close()
        asyncio.set_event_loop(None)

print(run_coro(work("a")))
print(run_coro(work("b")))
📊 Production Insight
A sync-to-async bridge stored self.loop in __init__ and scheduled tasks per request. The first request after each deploy worked, then all later ones raised. Building the loop inside the per-request runner fixed it in one commit.
🎯 Key Takeaway
Keep loop references local to one run — never cache them in globals, attributes, or default args.

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.

session_cleanup_order.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import asyncio

class FakeSession:
    def __init__(self):
        self.closed = False

    async def get(self, url):
        await asyncio.sleep(0.01)
        return f"200 {url}"

    async def close(self):
        await asyncio.sleep(0)
        self.closed = True

async def main():
    session = FakeSession()
    try:
        print(await session.get("https://example.com"))
    finally:
        await session.close()
    return session.closed

print("session closed cleanly:", asyncio.run(main()))
⚠ Own the Session Inside the Run
Create the session where you use it and close it before the coroutine returns. A session that outlives its loop is a crash waiting for the next run.
📊 Production Insight
A webhook service hoisted its session to module scope to save connection setup. Restarts then emitted connector warnings followed by loop-closed crashes. Moving the session into an async with block per run cleared both.
🎯 Key Takeaway
Sessions live and die inside one run: create after the loop starts, close before it stops.

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.

portable_loop_run.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import asyncio
import sys

print("platform:", sys.platform)

async def ping():
    await asyncio.sleep(0.01)
    return "pong"

def portable_run(coro):
    loop = asyncio.new_event_loop()
    try:
        asyncio.set_event_loop(loop)
        return loop.run_until_complete(coro)
    finally:
        loop.close()
        asyncio.set_event_loop(None)

print(portable_run(ping()))
📊 Production Insight
A scraping worker passed on Linux CI for months while Windows runners flaked with selector warnings and dead loops. One guarded set_event_loop_policy call at startup aligned both platforms.
🎯 Key Takeaway
On Windows set the selector policy once at startup — or write policy-agnostic code and skip the issue.

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.

library_loop_pattern.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import asyncio

# Good library shape: use the running loop, or take one as an argument
async def library_call(url, loop=None):
    running = loop or asyncio.get_running_loop()
    await asyncio.sleep(0.01)
    return f"fetched {url} on {id(running)}"

async def main():
    first = await library_call("https://a.example")
    second = await library_call("https://b.example")
    return first, second

for line in asyncio.run(main()):
    print(line)
📊 Production Insight
A shared SDK called 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.
🎯 Key Takeaway
Libraries take loops as arguments or read the running loop — they never fetch or cache one.

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.

loop_per_thread.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import asyncio
import threading

async def job(n):
    await asyncio.sleep(0.01)
    return n + 1

def thread_main(n, out):
    out[n] = asyncio.run(job(n))  # fresh loop owned by this thread

out = {}
threads = [threading.Thread(target=thread_main, args=(i, out)) for i in range(3)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(out)
📊 Production Insight
A fan-out service shared one loop across eight threads and closed it in the fastest finisher. The other seven crashed mid-flight nightly. Giving each thread its own asyncio.run() ended the flakiness in a day.
🎯 Key Takeaway
One loop per thread, built and closed on the owning thread — never share or close across threads.
● Production incidentPOST-MORTEMseverity: high

The Shared aiohttp Session That Died on the Second Batch

Symptom
At 2:04 AM the fan-out worker's second batch failed with RuntimeError: Event loop is closed. The first batch delivered fine. Retries re-fired the same failure, provider rate limits tripped, and the on-call engineer got paged for a 38-minute delivery stall affecting roughly 12,000 webhooks.
Assumption
The team assumed the session was loop-agnostic — create it once, reuse it forever, let the framework handle teardown. Code review focused on the retry logic in the new batching code, and nobody traced which loop the shared session was bound to. Staging didn't catch it because the staging job only ever processed one batch per run.
Root cause
The shared ClientSession had been created under the first batch's loop. When the second 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.
Fix
The session moved inside the coroutine: each batch built its own ClientSession in an async with block, so connector cleanup ran on the live loop before 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.
Key lesson
  • 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.
Production debug guideFive checks that separate a dead loop, a leaked session, a Windows policy gap, and a cached loop.5 entries
Symptom · 01
Script works once, then fails with RuntimeError on the second run or second batch
→
Fix
Run with 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.
Symptom · 02
Traceback names run_until_complete or _check_closed but not your logic
→
Fix
Insert 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.
Symptom · 03
aiohttp connector warnings appear alongside the loop-closed error
→
Fix
Run 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.
Symptom · 04
Failures only on Windows CI while Linux passes
→
Fix
Run 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.
Symptom · 05
Error appears after reload, re-import, or the second pytest case
→
Fix
Run 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.
Event Loop Closed — Causes and Fixes at a Glance
Root CauseHow to ConfirmFixPrevention
asyncio.run() closed the loop, code reuses itloop.is_closed() is True; second run_until_complete() raises RuntimeErrorCreate a fresh loop per run with asyncio.run() or asyncio.new_event_loop()Never store a loop across runs; build it where you use it
aiohttp session outlives its loopUnclosed-connector warnings plus loop-closed errors after restartCreate the session inside main() and await session.close() firstOwn the session inside the coroutine's try/finally block
Windows Proactor default differsFailures only on Windows CI; selector warnings in logsPin the policy once at startup or use policy-agnostic loop setupTest async code on Windows CI, not just Linux
get_event_loop() in library codeLoop mismatch or deprecation warnings across threadsUse get_running_loop() inside coroutines; accept loop as a paramBan get_event_loop() in shared code via linting
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
loop_reuse_demo.pyasync def fetch(n):asyncio.run() Closes Its Loop
fresh_loop_runner.pyasync def work(tag):Reusing Loops and Clients After Close
session_cleanup_order.pyclass FakeSession:aiohttp Cleanup Order
portable_loop_run.pyprint("platform:", sys.platform)Windows Proactor Warning
library_loop_pattern.pyasync def library_call(url, loop=None):Never Call get_event_loop() in Libraries
loop_per_thread.pyasync def job(n):One Loop Per Thread

Key takeaways

1
asyncio.run() creates a loop, runs your coroutine, then closes the loop forever
never schedule on it again.
2
One entry point gets one asyncio.run() call; sequential jobs mean sequential calls, each with a fresh loop.
3
Close aiohttp sessions inside the coroutine before it returns, so connector cleanup runs on the live loop.
4
Windows defaults to the Proactor loop
set the policy once at startup if your code needs selector APIs.
5
Libraries must use get_running_loop() inside coroutines, never get_event_loop() at module scope.
6
Each thread needs its own loop; sharing a loop across threads guarantees close-races.

Common mistakes to avoid

5 patterns
×

Calling asyncio.run() inside a function that is already running in a loop

Symptom
RuntimeError about a running loop, or the loop-closed error when a nested asyncio.run() shuts down the shared loop.
Fix
Call asyncio.run(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

Symptom
First request batch works, every batch after a restart fails with loop-closed or unclosed-connector warnings.
Fix
Create the session inside the coroutine that 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

Symptom
Intermittent RuntimeError in long-lived workers; works after a fresh deploy, breaks after the first reload or test.
Fix
Pass running loops explicitly or call 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

Symptom
Proactor-only warnings and loop-closed errors on Windows CI while Linux passes cleanly.
Fix
Set the policy once at startup on Windows (asyncio.WindowsSelectorEventLoopPolicy) or write policy-agnostic code with asyncio.new_event_loop().
×

Closing the loop while tasks or connectors are still pending

Symptom
'Task was destroyed but it is pending' plus loop-closed errors from connectors cleaned up after close.
Fix
Await every cleanup step before the loop closes: cancel tasks with asyncio.gather(return_exceptions=True), then close sessions, then let asyncio.run() close the loop.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What does asyncio.run() do with the event loop, and why can't you reuse ...
Q02JUNIOR
Why does calling run_until_complete() on a closed loop raise instead of ...
Q03SENIOR
Why does an aiohttp session created outside main() break under asyncio.r...
Q04SENIOR
What is the Windows Proactor pitfall, and why is get_event_loop() risky ...
Q05SENIOR
How do you design worker threads that each run asyncio without loop-clos...
Q01 of 05JUNIOR

What does asyncio.run() do with the event loop, and why can't you reuse it?

ANSWER
The event loop drives coroutines, callbacks, and I/O. 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.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
How do I run two coroutines in sequence without hitting this error?
02
Does asyncio.run() close the loop even when my code raises?
03
How do I shut down aiohttp cleanly under asyncio.run()?
04
Is this the same as the 'loop is already running' error?
05
Why does my code pass on Linux but warn on Windows?
06
How do I avoid this error in Jupyter notebooks?
N
Naren Founder & Principal Engineer

20+ years shipping production Python across data and backend systems. Drawn from code that ran under real load.

Follow
✓ Verified
production tested
September 24, 2026
last updated
1,942
articles · all by Naren
🔥

That's Errors. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
pip Distutils Uninstall Fix
12 / 18 · Errors
Next
Python BrokenPipeError 32 Fix
→