Skip to content

Choosing Proactor vs Selector Event Loops on Windows

On Linux and macOS, asyncio has one sensible event loop — the selector loop over epoll or kqueue — and the only real choice is whether to swap in uvloop. On Windows there are two loops with different capabilities, and code that is portable on paper fails on Windows in ways that look like library bugs. The default since Python 3.8 is the proactor loop, built on I/O completion ports. It supports subprocesses and scales to many sockets, but it does not implement add_reader() / add_writer(), which several libraries depend on. The alternative selector loop supports those calls but cannot run subprocesses and is capped by select() at 512 sockets. This guide maps out which loop each workload needs and how to select it on Python 3.12–3.14 without the deprecated policy API.

Prerequisites

  • Python 3.11+ on Windows; the loop selection examples use loop_factory, available in asyncio.run() from 3.12 and in asyncio.Runner from 3.11.
  • How the loop is created, from how to properly configure asyncio event loops for production.
  • Readiness vs completion I/O at a conceptual level: a selector tells you a socket can be read; a proactor tells you a read has finished.

1. Know what each loop can and cannot do

The two loops differ at the level of the operating-system interface they wrap, and the differences show up as missing methods rather than slow ones:

Capability ProactorEventLoop (default) SelectorEventLoop
Underlying mechanism I/O completion ports select()
add_reader / add_writer NotImplementedError supported
Subprocesses supported not supported
Socket count not limited by the loop 512 (select() limit in CPython)
Pipes and named pipes supported not supported
add_signal_handler not supported on Windows not supported on Windows

Signal handlers are missing from both — loop.add_signal_handler() raises NotImplementedError on Windows regardless of loop — so graceful shutdown code written around SIGTERM handlers needs a Windows path, as covered in handling SIGTERM in asyncio services.

Verify: on your Windows target, print type(asyncio.get_running_loop()).__name__ at startup and log it; it should match the loop you meant to run.

Proactor and selector loops on Windows A grid of 4 rows by 3 columns. Proactor and selector loops on Windows capability proactor (default) selector add_reader / add_writer not implemented supported subprocesses and pipes supported not supported socket ceiling none from the loop 512 via select() needed by subprocess-heavy tools psycopg async, some DNS libraries Neither loop is strictly better; each is missing something the other has.

2. Recognise the libraries that need the selector loop

Libraries that integrate a C library's own socket handling with asyncio usually do it through add_reader(): they let the C library own the socket and ask the loop to call back when it becomes readable. On the proactor loop that call raises NotImplementedError, typically wrapped in a library-specific error at connect time. psycopg 3 in async mode is the best-known case: it refuses to run on the proactor loop and its error message tells you to use a selector loop.

import asyncio
import sys
import psycopg


async def main() -> None:
    async with await psycopg.AsyncConnection.connect(DSN) as conn:
        async with conn.cursor() as cur:
            await cur.execute("select 1")
            print(await cur.fetchone())


if sys.platform == "win32":
    asyncio.run(main(), loop_factory=asyncio.SelectorEventLoop)
else:
    asyncio.run(main())

On Windows, asyncio.SelectorEventLoop is the Windows selector implementation, so the same name works on every platform. Pure-Python async drivers such as asyncpg use transports rather than add_reader(), and run on either loop.

Verify: run the connection test on Windows with the default loop and confirm the library's error, then with the selector loop and confirm it connects.

3. Respect the selector loop's limits

Switching to the selector loop trades away two things. First, subprocesses: asyncio.create_subprocess_exec() raises NotImplementedError on the Windows selector loop, so a service that both talks to psycopg and shells out to tools cannot use one loop for both. Second, scale: CPython's select() on Windows handles at most 512 sockets, and the loop fails once more are registered.

When you need both capabilities, split them across loops or processes:

import asyncio
import subprocess


async def run_tool(args: list[str]) -> bytes:
    # on the selector loop: run the blocking subprocess API in a thread instead
    proc = await asyncio.to_thread(
        subprocess.run, args, capture_output=True, timeout=60, check=True
    )
    return proc.stdout

Running the blocking subprocess.run in a thread works on every loop and keeps subprocess handling out of the event loop entirely, at the cost of one thread per concurrent subprocess. For a large number of concurrent subprocesses, run them from a separate process that uses the proactor loop. The asyncio-native subprocess patterns are in running subprocesses with asyncio.create_subprocess_exec.

Verify: under peak load on the selector loop, the process's open socket count stays well under 512.

Which loop should this Windows process run? A decision on What does the process need with 3 outcomes. Which loop should this Windows process run? What does the process need? a library that uses add_reader SelectorEventLoop under 512 sockets subprocesses or many sockets ProactorEventLoop the default both at once split processes or to_thread for subprocesses Pick per process; mixing incompatible needs in one loop is what produces the confusing errors.

4. Select the loop without the deprecated policy API

For years the advice was asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()). Event loop policies are deprecated as of Python 3.14 and scheduled for removal, and setting them emits a DeprecationWarning. The replacement is to pass a factory to whatever starts the loop:

import asyncio
import sys


def make_loop() -> asyncio.AbstractEventLoop:
    if sys.platform == "win32" and NEEDS_ADD_READER:
        return asyncio.SelectorEventLoop()
    return asyncio.new_event_loop()          # proactor on Windows, selector elsewhere


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

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

Frameworks that create the loop themselves — uvicorn, pytest-asyncio, Jupyter — each have their own setting for this, because they never call your asyncio.run(). Look for the framework's own loop option (a CLI flag, a fixture, a config key) rather than setting a global policy that it may or may not consult. The full migration is in migrating off event loop policies in Python 3.14.

Verify: run with -W error::DeprecationWarning on Python 3.14; no policy-related warning should be raised.

5. Test on Windows, not just for Windows

Most Windows-only asyncio failures are missing-method errors that a single test run on Windows would catch, and almost none show up on Linux CI. Add a Windows job that runs at least the integration tests, and make the loop under test explicit:

import asyncio
import sys
import pytest


@pytest.mark.skipif(sys.platform != "win32", reason="Windows loop behaviour")
def test_runs_on_selector_loop():
    async def probe():
        loop = asyncio.get_running_loop()
        return type(loop).__name__

    name = asyncio.run(probe(), loop_factory=asyncio.SelectorEventLoop)
    assert "Selector" in name

Also test shutdown on Windows: Ctrl-C delivery, the lack of add_signal_handler, and pipe transports closing at interpreter exit behave differently from POSIX, and the differences only appear when the process actually stops. uvloop does not support Windows, so a configuration that enables it conditionally must fall back cleanly — see benchmarking uvloop against the default event loop.

Verify: the Windows CI job runs the same integration suite as Linux, including a start-and-stop test of the service entry point.

A Windows check that catches loop mismatches early A flow of 4 stages. A Windows check that catches loop mismatches early start on chosen loop log its class name integration tests same suite as Linux subprocess + DB paths the loop-specific calls stop the service Ctrl-C, clean exit Missing-method errors only appear when the code actually runs on Windows.

Verification

The loop choice is right when:

  • The loop class is logged at startup and matches the one the process needs.
  • No NotImplementedError from add_reader, subprocess creation or signal handlers appears in Windows test runs.
  • The selector loop's socket count stays comfortably under 512 at peak.
  • No policy API is used, so Python 3.14+ runs without deprecation warnings.

Diagnostic Hook: include platform, loop class and Python version as labels on your process-info metric. When a Windows-only incident appears, the first question is always "which loop was it on?", and having it in metrics answers it without a repro. Alert on open socket count above 400 for processes on the selector loop.

Pitfalls & edge cases

  • Setting the selector loop globally for one library. Every subprocess call in the process breaks; scope the choice to the process that needs it.
  • Assuming signal handlers work on Windows. They do not on either loop; use asyncio.Runner's Ctrl-C handling or a platform-specific shutdown path.
  • Copying a policy-based snippet from an old answer. It still works on 3.13 and warns on 3.14; use loop_factory.
  • "Event loop is closed" at exit on older Python versions. Proactor transports finalised after the loop closed produced this noise in older Python and aiohttp versions; close clients explicitly before the loop ends rather than suppressing the error.

Frequently Asked Questions

What is the default asyncio event loop on Windows?

Since Python 3.8 it is ProactorEventLoop, built on I/O completion ports. It supports subprocesses and pipes but does not implement add_reader and add_writer.

Why does psycopg async fail on Windows?

psycopg 3's async mode relies on add_reader, which the default proactor loop does not implement. Run it on asyncio.SelectorEventLoop, for example with asyncio.run(main(), loop_factory=asyncio.SelectorEventLoop).

How do I use SelectorEventLoop on Windows without set_event_loop_policy?

Pass a loop factory: asyncio.run(main(), loop_factory=asyncio.SelectorEventLoop) on Python 3.12+, or asyncio.Runner(loop_factory=...) on 3.11+. Event loop policies are deprecated as of Python 3.14.

Can the selector loop run subprocesses on Windows?

No. asyncio subprocess functions raise NotImplementedError on the Windows selector loop. Run blocking subprocess calls in a thread, or keep subprocess work in a separate process on the proactor loop.