A fast, drop-in asyncio event loop, powered by libuv and written in Zig β‘
Documentation: https://zuvloop.marcelotryle.com
Source Code: https://github.com/Kludex/zuvloop
zuvloop is a replacement for the built-in asyncio event loop.
Your code stays the same. The loop underneath gets faster. π
The key features are:
- Fast: Scheduling, timers, sockets, and DNS run in native code, driven by libuv β the same engine behind Node.js. Up to 6x faster than asyncio and faster than uvloop on every benchmark below.
- Drop-in: One line to switch. Everything is standard
asyncioβ sameTaskobjects, same protocols, same APIs. - Fully typed: Ships type hints for everything and passes strict mypy. Your editor will love it. β¨
- Observable: Built-in OpenTelemetry instrumentation β slow-callback spans, unhandled-exception spans, loop metrics. Zero cost until you turn it on.
- Modern: Built for Python 3.14, including the new asyncio introspection tools (
python -m asyncio ps, call graphs, and friends).
Throughput relative to stock asyncio (higher is better), measured with the suite in
benchmarks/ on an M3 Max, macOS 26, CPython 3.14. The labels show the absolute numbers.
| Benchmark | asyncio | uvloop | zuvloop |
|---|---|---|---|
call_soon |
2.59M/s | 5.66M/s | 5.90M/s |
call_soon with arguments |
2.45M/s | 3.55M/s | 6.36M/s |
call_soon_threadsafe |
0.44M/s | 5.06M/s | 6.51M/s |
| timer schedule + cancel | 1.55M/s | 2.57M/s | 9.58M/s |
| bulk stream | 8.6 GiB/s | 9.0 GiB/s | 10.7 GiB/s |
| echo round trips, 1 KiB | 38.4k/s | 53.0k/s | 58.4k/s |
| uvicorn, plaintext | 54.0k req/s | 70.5k req/s | 76.7k req/s |
| uvicorn, 10 KiB body | 51.6k req/s | 69.5k req/s | 74.2k req/s |
| aiohttp server | 48.8k req/s | 61.0k req/s | 62.6k req/s |
| aiohttp client | 13.4k req/s | 16.6k req/s | 16.9k req/s |
getaddrinfo, numeric host |
28.3k/s | 1.58M/s | 1.91M/s |
Curious how? The architecture docs explain the design:
argument storage inside handles (no tuple per callback), a native timer heap behind a single
uv_timer_t, per-turn vectored write batching, zero-copy reads, and a getaddrinfo fast path
for address literals.
- Python 3.14+
- Linux or macOS
$ pip install zuvloopTo build from source you also need Zig 0.16.
Write normal asyncio code, run it with zuvloop:
import asyncio
import zuvloop
async def main() -> None:
reader, writer = await asyncio.open_connection("example.com", 80)
writer.write(b"GET / HTTP/1.0\r\nHost: example.com\r\n\r\n")
await writer.drain()
print(await reader.read(64))
writer.close()
await writer.wait_closed()
zuvloop.run(main())Prefer to keep asyncio.run()? Hand it the loop factory:
asyncio.run(main(), loop_factory=zuvloop.new_event_loop)That's it. That's the migration. π
zuvloop emits plain OpenTelemetry. The only runtime dependency is opentelemetry-api β not the
SDK, nothing vendor-specific. Providers can be configured before the loop starts or from inside
it β logfire.configure() in main() works: zuvloop checks at each run_forever() entry and
re-checks on its sampling interval (loop.metrics_interval, 10 seconds by default) while the
loop runs. Until a provider is installed the instruments are no-ops and slow-callback timing
stays off.
Anything that speaks OpenTelemetry can collect it. For example, with Logfire:
import logfire
import zuvloop
logfire.configure() # installs the OTel providers
async def main() -> None: ...
zuvloop.run(main())That's all β there is no zuvloop-specific setup. Spans and counters are emitted as events happen, and the loop gauges are sampled automatically while the loop runs (published only once a real provider is installed; without one the snapshot is dropped).
You get:
zuvloop.slow_callbackspans β with real start/end timestamps measured byuv_hrtime()in native code, and the awaiting call graph attached (viaasyncio.format_call_graph()), so you see why a callback was running, not just its repr.zuvloop.unhandled_exceptionspans β with the exception recorded.- Counters, a callback-duration histogram, and live loop gauges (
loop_count,events,idle_time_ns,ready,timers,watchers, ...).
And because zuvloop schedules real asyncio.Task objects, the Python 3.14 introspection tools
work unchanged:
$ python -m asyncio ps <pid>
$ python -m asyncio pstree <pid>zuvloop is checked against CPython's own conformance suite and against the test suites of the projects that exercise an event loop hardest β run unmodified, with the loop swapped underneath:
| Suite | Result |
|---|---|
CPython test_asyncio |
88 passed, 4 skipped, none failing |
| uvicorn | 1257 passed, no failures |
| aiohttp | 4473 passed, 36 failed β 33 of which also fail on stock asyncio |
scripts/conformance.py runs CPython's EventLoopTestsMixin, SubprocessTestsMixin and
BaseSockTestsMixin against zuvloop, downloading the source of whichever interpreter is running
so the suite always matches it. Each test runs in its own process, so a hang is reported rather
than stopping the run. Three of the four skips are white-box tests of CPython's own internals -
two patch asyncio.base_events.socket, one counts calls to BaseEventLoop._run_once - which no
loop outside the standard library can satisfy.
Of aiohttp's three remaining failures, two are blockbuster reporting a blocking os.stat that
the standard library makes on the same path, and the third is the loop.time() difference below.
(For reference: uvloop cannot complete the aiohttp suite β it fails fifteen tests and then hangs.)
There is one intentional difference: patching loop.time() does not move the scheduler.
zuvloop keeps its timer heap in native code and reads the clock directly, so monkeypatching
time() β a trick some test suites use to fast-forward timeouts β changes what loop.time()
returns and nothing else. A loop that needs a controllable clock should schedule against one
explicitly.
One more deliberate divergence: handles
returned by call_soon implement the asyncio.Handle interface but are not instances of it:
the base class is 56 bytes of storage such a handle never writes, measured at 2% of call_soon,
which is the object the loop allocates more often than any other. call_later and call_at
do return real asyncio.TimerHandle instances, so they order and compare by deadline, and
call_soon_threadsafe returns a real asyncio.Handle, because 3.14 requires cancelling one
from another thread to block until a callback that has already started finishes.
$ uv venv --python 3.14
$ uv pip install -e . --group dev
$ uv run pytest
$ uv run mypy
$ uv run ruff check
$ uv run ruff format --check
$ ./scripts/check-zig # requires ZLint 0.9.1 on PATH
$ uv run --group bench python benchmarks/run.pyThe extension is rebuilt by hatch_build.py on every install. To rebuild in place:
$ python scripts/build.pyvendor/libuv is an unmodified upstream release tarball; see vendor/README.md. Update it with
./vendor/update-libuv.sh <version> <sha256>.
This project is licensed under the terms of the MIT license.