Replacing get_event_loop Deprecation Warnings¶
asyncio.get_event_loop() is the most common reason an asyncio codebase breaks on Python 3.14. Its behaviour depends on where it is called: inside a running coroutine it returns the running loop on every version, which is why most code never notices. Outside one — at module level, in a script's entry point, in a fixture — it used to create a loop silently, then warned, and on 3.14 raises RuntimeError: There is no current event loop in thread 'MainThread'. Run on CPython 3.10 through 3.14, the probe also showed two cases that were already broken everywhere: calling it from a non-main thread raised RuntimeError on all five versions, and so did calling it from the main thread after an asyncio.run() had finished. This guide walks through the five patterns that call get_event_loop() and replaces each.
Prerequisites¶
- Python 3.11+ for the replacements that use
asyncio.Runner; the rest work on 3.7+. - The version map, from Asyncio Across Python Versions.
- Entry points, from when to use asyncio.run vs loop.run_until_complete.
1. Find every call and classify it¶
Make the warnings fatal, run the suite, and grep:
python -W error::DeprecationWarning -m pytest -x # 3.12 or 3.13
grep -rn "get_event_loop()" --include=*.py src tests
Every call falls into one of five patterns, and each has a different fix:
| Pattern | Where it appears | Replacement |
|---|---|---|
| running a coroutine from sync code | main(), scripts, CLIs |
asyncio.run() or asyncio.Runner |
| needing the loop inside async code | handlers, libraries | asyncio.get_running_loop() |
grabbing a loop at import or in __init__ |
module globals, constructors | defer until a coroutine runs |
| scheduling from another thread | worker threads, callbacks | pass the loop in explicitly |
| tests that build loops by hand | fixtures, helpers | the test plugin's loop or Runner |
Verify: after the fixes, the strict-warnings run is clean on 3.12/3.13 and the suite passes on 3.14.
2. Replace run_until_complete entry points¶
The classic script entry point:
# before
loop = asyncio.get_event_loop()
try:
loop.run_until_complete(main())
finally:
loop.close()
# after
asyncio.run(main())
asyncio.run() also does three things the old version usually skipped: it cancels remaining tasks, shuts down async generators, and shuts down the default executor. If the old code called run_until_complete several times on one loop — set up, run, tear down — use a Runner, which keeps the loop open between calls:
with asyncio.Runner() as runner:
runner.run(setup())
runner.run(main())
runner.run(teardown())
The Runner is covered in detail in reusing one loop across calls with asyncio.Runner.
Verify: the script runs without warnings on 3.12 and without errors on 3.14.
3. Use get_running_loop inside async code¶
Inside a coroutine, get_event_loop() returns the running loop on every version, so it does not warn — but get_running_loop() says what you mean and fails loudly if called somewhere unexpected:
async def fetch(url: str) -> bytes:
loop = asyncio.get_running_loop() # not get_event_loop()
return await loop.run_in_executor(None, _blocking_fetch, url)
Callbacks invoked by the loop — protocol methods, call_soon targets — also run with a running loop, so get_running_loop() works there too. The only place it fails is code that genuinely runs outside the loop, which is exactly the code that needs fixing anyway.
Verify: replace every get_event_loop() inside async def with get_running_loop(); the suite still passes.
4. Stop acquiring loops at import time¶
Libraries and services often grab a loop when a module is imported or an object is constructed:
# before: runs at import, creates or grabs a loop that may never be the running one
_loop = asyncio.get_event_loop()
_lock = asyncio.Lock() # also bound lazily to "a" loop
class Client:
def __init__(self) -> None:
self._loop = asyncio.get_event_loop() # breaks when constructed outside a loop
self._ready = self._loop.create_future()
# after: acquire the loop when async code first runs
class Client:
def __init__(self) -> None:
self._ready: asyncio.Future | None = None
async def start(self) -> None:
self._ready = asyncio.get_running_loop().create_future()
Module-level loops were always fragile — after asyncio.run() finishes, the main-thread call raises on every version, as the probe showed — and they bind objects to a loop that later code may not be using, producing attached to a different loop errors. Constructors that need a loop should become async factories or defer the work to a start() method. The binding problem is explained in creating futures with loop.create_future.
Verify: importing every module with no loop running emits no warnings and creates no loops.
5. Pass the loop to threads explicitly¶
Worker threads have never had a current loop — get_event_loop() from a non-main thread raised on all five versions tested. Code that "worked" was always getting a loop some other way. The thread needs a reference to the loop it should schedule onto, captured while that loop is running:
import threading
def worker(loop: asyncio.AbstractEventLoop, results: asyncio.Queue) -> None:
for item in produce():
loop.call_soon_threadsafe(results.put_nowait, item)
async def main() -> None:
loop = asyncio.get_running_loop() # captured on the loop thread
results: asyncio.Queue = asyncio.Queue()
threading.Thread(target=worker, args=(loop, results), daemon=True).start()
while True:
print(await results.get())
Never call asyncio.set_event_loop(loop) in a worker thread to make get_event_loop() "work" there: it makes the thread look like it owns a loop it does not run, and any non-thread-safe call it then makes corrupts the loop. The thread-to-loop patterns are in sending results from threads to an asyncio queue.
Verify: grep worker-thread code for get_event_loop and set_event_loop; neither should appear.
6. Fix tests that build loops by hand¶
Old test helpers create loops directly and then call get_event_loop() somewhere downstream:
# before
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
# after: one runner for the session, or the plugin's loop
@pytest.fixture(scope="session")
def runner():
with asyncio.Runner() as r:
yield r
def test_thing(runner):
assert runner.run(compute()) == 42
With pytest-asyncio or AnyIO's plugin, prefer writing async def tests and letting the plugin own the loop. In strict-warnings mode both plugins run cleanly on 3.12+, so any remaining warning points at your own helpers. Fixture scopes are covered in choosing event loop scopes in pytest-asyncio.
Verify: the test suite passes on 3.14 with no RuntimeError from loop acquisition.
Verification¶
The migration is complete when:
grep "get_event_loop()"finds nothing outside third-party code.- A strict run with
-W error::DeprecationWarningon 3.12 or 3.13 is clean. - The suite passes on 3.14, where any missed call raises.
- Importing modules creates no loops and binds no asyncio primitives.
Diagnostic Hook: during the rollout, ship with PYTHONWARNINGS=default::DeprecationWarning in staging and count warnings whose message mentions "no current event loop" per module. That count is the remaining work list; when it hits zero across a week of traffic, the 3.14 upgrade is safe from this class of failure.
Pitfalls & edge cases¶
- Replacing with
asyncio.new_event_loop()+set_event_loop()everywhere. It silences the warning and keeps the fragility; preferasyncio.run(). - Third-party libraries still calling it. They warn under your strict filter; scope an ignore to their module and track the upstream fix.
- Calling
get_running_loop()in sync helpers that are sometimes used outside a loop. Make those helpers take the loop, or make them async. - Assuming the old behaviour was reliable. After
asyncio.run(), the main-thread call already raised on 3.10.
Frequently Asked Questions¶
Why does asyncio.get_event_loop() show a DeprecationWarning?
From Python 3.12, calling it when no event loop is running and none has been set warns that it will stop creating one implicitly. On 3.14 the same call raises RuntimeError.
What should I use instead of asyncio.get_event_loop()?
In synchronous entry points, run coroutines with asyncio.run() or asyncio.Runner. Inside coroutines and loop callbacks, use asyncio.get_running_loop(). In worker threads, pass the loop reference in from the loop thread.
Is asyncio.get_event_loop() deprecated inside coroutines?
No. Inside a running coroutine it returns the running loop on every version from 3.10 to 3.14 without warning. get_running_loop() is still clearer because it fails loudly when no loop is running.
Why does get_event_loop fail after asyncio.run?
asyncio.run clears the current loop when it finishes, and after that the main-thread call raises RuntimeError on every version tested from 3.10 to 3.14. Code that relies on it after a run was already broken.
Related¶
- Asyncio Across Python Versions — up to the topic overview.
- Migrating off event loop policies in Python 3.14 — the other 3.14 entry-point change.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.