Skip to content

Migrating Off Event Loop Policies in Python 3.14

Event loop policies were asyncio's original extension point: install a policy object, and every call that creates a loop — asyncio.run(), get_event_loop(), new_event_loop() — asks it which class to build. It is how uvloop.install() works and how Windows code selected the selector loop. Python 3.14 deprecated the whole mechanism for removal in 3.16. On 3.14 with warnings as errors, a custom policy failed with DeprecationWarning: 'asyncio.DefaultEventLoopPolicy' is deprecated and slated for removal in Python 3.16, and uvloop.install() failed with the equivalent message for AbstractEventLoopPolicy. The replacement — a loop_factory passed to asyncio.run() (3.12+) or asyncio.Runner (3.11+) — produced the requested loop class on every version from 3.11 to 3.14 with no warnings. This guide migrates each common policy use.

Prerequisites

1. Find every policy use

The deprecated surface is wide; search for all of it:

grep -rnE "set_event_loop_policy|get_event_loop_policy|EventLoopPolicy|uvloop\.install" \
     --include=*.py src tests

Each hit is one of four things: selecting uvloop, selecting the Windows selector loop, installing a custom loop subclass (for instrumentation, usually), or test code resetting a policy between tests. Run the suite on 3.14 with -W error::DeprecationWarning to catch uses inside your dependencies as well; those need upgrading or an upstream fix rather than a local change.

Verify: you have a list of call sites grouped by those four purposes.

Policy uses and their loop_factory replacements A grid of 4 rows by 3 columns. Policy uses and their loop_factory replacements policy use replacement available from uvloop.install() uvloop.run() or loop_factory=uvloop.new_event_loop 3.12 (Runner: 3.11) WindowsSelectorEventLoopPolicy loop_factory=asyncio.SelectorEventLoop 3.12 (Runner: 3.11) custom policy for a loop subclass loop_factory=MyLoop 3.12 (Runner: 3.11) resetting policy in tests nothing: no global state - Every use becomes an argument at the point where the loop is created.

2. Replace uvloop.install()

uvloop.install() sets a policy; on 3.14 it warns. uvloop provides uvloop.run(), which passes a loop factory itself, and its new_event_loop works as a factory anywhere:

# pip install uvloop
import asyncio
import uvloop


# before: global, deprecated on 3.14
uvloop.install()
asyncio.run(main())

# after, option 1: uvloop's own runner
uvloop.run(main())

# after, option 2: explicit factory, composes with other choices
asyncio.run(main(), loop_factory=uvloop.new_event_loop)       # 3.12+

Verified with uvloop 0.23 on 3.14 under -W error::DeprecationWarning: both replacements ran on a uvloop loop, and install() raised the deprecation. Option 2 is better in applications that pick the loop from configuration, because the factory can fall back to the default loop when uvloop is unavailable — on Windows, for instance, where uvloop does not run.

Verify: type(asyncio.get_running_loop()).__module__ is uvloop inside main().

3. Replace the Windows selector policy

Windows code that needs add_reader() — psycopg's async mode is the best-known case — selected the selector loop with a policy. Replace it with a factory scoped to the process that needs it:

import asyncio
import sys


def main_entry() -> None:
    if sys.platform == "win32":
        asyncio.run(main(), loop_factory=asyncio.SelectorEventLoop)
    else:
        asyncio.run(main())

On Windows, asyncio.SelectorEventLoop names the Windows selector implementation, so no platform-specific class needs importing. Unlike the global policy, the factory affects only this asyncio.run() call, so a subprocess-heavy tool in the same codebase can keep the proactor loop. The capability differences between the two loops are in choosing proactor vs selector event loops on Windows.

Verify: on Windows, the psycopg connection test passes and no policy warning appears.

4. Replace custom policies for loop subclasses

Instrumentation often subclasses a loop and installs it through a policy so that every asyncio.run() picks it up:

# before
class InstrumentedLoop(asyncio.SelectorEventLoop):
    def _run_once(self):
        start = time.perf_counter()
        super()._run_once()
        LOOP_ITERATION.observe(time.perf_counter() - start)


class Policy(asyncio.DefaultEventLoopPolicy):
    def new_event_loop(self):
        return InstrumentedLoop()


asyncio.set_event_loop_policy(Policy())
asyncio.run(main())

# after
asyncio.run(main(), loop_factory=InstrumentedLoop)              # 3.12+

with asyncio.Runner(loop_factory=InstrumentedLoop) as runner:   # 3.11
    runner.run(main())

Measured across versions: the policy approach produced InstrumentedLoop on 3.11–3.13 and raised DeprecationWarning on 3.14 under strict warnings; Runner(loop_factory=…) produced it on 3.11–3.14 and asyncio.run(…, loop_factory=…) on 3.12–3.14. Overriding a private method like _run_once is itself fragile across versions; prefer public hooks such as the slow-callback log or a task factory, as in logging task lifecycles with a custom task factory.

Verify: type(asyncio.get_running_loop()) is your subclass on every supported version.

Versions on which each loop-selection method works cleanly 3 horizontal bars comparing Runner(loop_factory=) with the others. Versions on which each loop-selection method works cleanly Runner(loop_factory=) 3.11, 3.12, 3.13, 3.14 asyncio.run(loop_factory=) 3.12, 3.13, 3.14 set_event_loop_policy 3.11-3.13; warns on 3.14 Measured with -W error::DeprecationWarning on CPython 3.11.15 to 3.14.4. The factory methods are the only ones that work cleanly on the current release.

5. Handle frameworks that create the loop

loop_factory only helps where your code calls asyncio.run(). Servers, test runners and notebooks create loops themselves, and each has its own setting:

# uvicorn: choose the loop implementation in its config, not via a policy
uvicorn.run("app:app", loop="uvloop")             # or "asyncio"

# pytest-asyncio / AnyIO: configure through the plugin
@pytest.fixture(params=[("asyncio", {"use_uvloop": True})])
def anyio_backend(request):
    return request.param

Check each framework's release notes for its 3.14 story: several used policies internally and have moved, or are moving, to factories. Until they do, scope a warning filter to the framework's module rather than silencing DeprecationWarning globally, so your own code stays strict. The test-runner side is covered in testing asyncio code across Python versions.

Verify: in staging on 3.14, the only remaining policy warnings come from modules you have explicitly listed.

Where does the loop get created? A decision on Who calls asyncio.run with 3 outcomes. Where does the loop get created? Who calls asyncio.run? my entry point loop_factory= 3.12+, Runner on 3.11 a server or test runner its own loop option e.g. uvicorn loop=uvloop a small script uvloop.run(main()) one line Policies were global; their replacements live at the point of loop creation.

6. Keep one helper while older versions are supported

Libraries and fleets that still support 3.10 need the policy path for that one version, because neither asyncio.Runner nor loop_factory exists there. Keep the branching in a single helper so the cleanup later is a one-file change:

import asyncio
import sys
import warnings
from collections.abc import Callable, Coroutine
from typing import Any, TypeVar

T = TypeVar("T")
Factory = Callable[[], asyncio.AbstractEventLoop]


def run_with_loop(main: Coroutine[Any, Any, T], factory: Factory) -> T:
    if sys.version_info >= (3, 12):
        return asyncio.run(main, loop_factory=factory)
    if sys.version_info >= (3, 11):
        with asyncio.Runner(loop_factory=factory) as runner:
            return runner.run(main)
    # 3.10 only: no factory support, so manage the loop by hand instead of a policy
    loop = factory()
    try:
        asyncio.set_event_loop(loop)
        return loop.run_until_complete(main)
    finally:
        loop.run_until_complete(loop.shutdown_asyncgens())
        asyncio.set_event_loop(None)
        loop.close()

The 3.10 branch avoids policies entirely by creating the loop itself, so the same helper is warning-free on every version from 3.10 to 3.14. When 3.10 and 3.11 leave your support window, the function collapses to its first branch — or to a direct asyncio.run(..., loop_factory=...) at the call site. Track that removal in the same place you track other version floors; the broader shim, with structured-concurrency and introspection branches, is shown on the topic overview.

Verify: run the helper on every supported interpreter with -W error::DeprecationWarning; each run uses the requested loop class and none warns.

Verification

The migration is done when:

  • No policy API appears in your code, confirmed by the grep in step 1.
  • A strict-warnings run on 3.14 reports no policy deprecations from your modules.
  • Each process runs the intended loop class, logged at startup.
  • Framework-created loops are configured through framework settings.

Diagnostic Hook: log the running loop's class and module once at startup in every process, and export it as a label on a process-info metric. During and after the migration this confirms that uvloop is still in use where it was before — the failure mode of removing uvloop.install() without adding a factory is a silent fall-back to the default loop and a quiet throughput regression.

Pitfalls & edge cases

  • Deleting uvloop.install() without a replacement. Everything still works, on the slower default loop.
  • Global warning filters. Silencing DeprecationWarning entirely hides the next removal too; scope filters to third-party modules.
  • Factories that return a loop already in use. A factory must create a new loop each call; asyncio.run() closes it at the end.
  • Version checks scattered through the code. Put the 3.11/3.12 branch in one helper, as in the shim on the topic overview.

Frequently Asked Questions

Are asyncio event loop policies deprecated?

Yes. Python 3.14 deprecates set_event_loop_policy, get_event_loop_policy and the policy classes, with removal planned for 3.16. Using them emits DeprecationWarning.

How do I use uvloop without uvloop.install()?

Call uvloop.run(main()), or pass loop_factory=uvloop.new_event_loop to asyncio.run on Python 3.12+ or to asyncio.Runner on 3.11. Both produce a uvloop loop without touching the deprecated policy API.

What replaces WindowsSelectorEventLoopPolicy?

Pass loop_factory=asyncio.SelectorEventLoop to asyncio.run or asyncio.Runner. On Windows that name refers to the selector-based loop, and the choice applies only to that run rather than the whole process.

What is loop_factory in asyncio.run?

An argument added in Python 3.12 (and available on asyncio.Runner since 3.11) that takes a callable returning a new event loop. asyncio.run uses it instead of the policy to create the loop it runs.