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.69M/s | 4.69M/s | 5.91M/s |
call_soon with arguments |
2.43M/s | 3.87M/s | 6.47M/s |
| timer schedule + cancel | 1.58M/s | 2.62M/s | 9.55M/s |
| bulk stream | 8.4 GiB/s | 8.5 GiB/s | 10.2 GiB/s |
| echo round trips, 1 KiB | 39.0k/s | 56.8k/s | 58.5k/s |
| uvicorn, plaintext | 55.3k req/s | 71.9k req/s | 75.9k req/s |
| uvicorn, 10 KiB body | 52.6k req/s | 68.7k req/s | 73.6k req/s |
| aiohttp server | 49.0k req/s | 59.8k req/s | 60.6k req/s |
| aiohttp client | 13.2k req/s | 16.2k req/s | 16.9k req/s |
getaddrinfo, numeric host |
28.5k/s | 1.57M/s | 1.90M/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. Configure providers before starting the loop; zuvloop checks for them
at each run_forever() entry. Until then 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 (only when a real provider is installed, so an uninstrumented program never pays for sampling).
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.
Also not implemented: sendfile() and sock_sendfile() raise NotImplementedError. Handles
returned by call_soon and call_later implement the asyncio.Handle and
asyncio.TimerHandle interfaces but are not instances of those classes; call_soon_threadsafe
does return 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 --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>.
This project is licensed under the terms of the MIT license.
