Skip to content

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

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.

get_event_loop() by call site and Python version A grid of 4 rows by 4 columns. get_event_loop() by call site and Python version call site 3.10-3.11 3.12-3.13 3.14 inside a running coroutine running loop running loop running loop main thread, nothing set yet creates a loop DeprecationWarning RuntimeError main thread after asyncio.run RuntimeError RuntimeError RuntimeError worker thread RuntimeError RuntimeError RuntimeError Measured on CPython 3.10.20 to 3.14.4; only the second row changed, but it is the one entry points hit.

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.

Move loop acquisition to where a loop exists A flow of 4 stages. Move loop acquisition to where a loop exists import time no loop exists yet __init__ may run outside a loop async start() get_running_loop() is safe entry point asyncio.run owns the loop Code that only touches the loop from inside coroutines is immune to every change in this area.

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.

Handing a worker thread the loop it should use A sequence of 5 messages between 3 participants. Handing a worker thread the loop it should use main coroutine event loop worker thread get_running_loop() Thread(args=(loop, queue)).start() produce an item call_soon_threadsafe(put_nowait, item) queue.get() returns the item The thread never looks a loop up; it is handed the one it must use.

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::DeprecationWarning on 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; prefer asyncio.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.