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¶
- Python 3.11+ for
Runner(loop_factory=...), 3.12+ forasyncio.run(..., loop_factory=...). - The version map, from Asyncio Across Python Versions.
- Loop choices, from benchmarking uvloop against the default event loop.
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.
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.
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.
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
DeprecationWarningentirely 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.
Related¶
- Asyncio Across Python Versions — up to the topic overview.
- Replacing get_event_loop deprecation warnings — the companion change at entry points.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.