- Python 100%
| docs | ||
| gen | ||
| proto/greet/v1 | ||
| .gitignore | ||
| .python-version | ||
| benchmark.py | ||
| benchmark_h2.py | ||
| buf.gen.yaml | ||
| buf.yaml | ||
| charts.py | ||
| client.py | ||
| pyproject.toml | ||
| README.md | ||
| server.py | ||
| server_h2.py | ||
| uv.lock | ||
pyrpc-demo
A minimal demo of a Python server and client communicating over protobuf using Buf (schema tooling) and Connect RPC (the RPC protocol and runtime). Everything is managed with uv — no global installs required.
What the pieces are
| Piece | Role in this project |
|---|---|
| uv | Python project/package manager. Owns the virtualenv and lockfile, and runs everything via uv run. |
Buf (buf-bin) |
Protobuf-native toolchain. Lints the schema, checks breaking changes, and generates Python code from .proto files. |
protobuf (protobuf-py) |
The serialization format. The new protobuf-py runtime (bundled as a dependency of connectrpc) powers the generated message classes. Pure Python at the core, accelerated by an optional Rust extension (protobuf-py-ext). |
Connect RPC (connectrpc) |
The RPC framework. Defines the protocol (plain HTTP/1.1 + protobuf bodies) and provides the server (ASGI) and client runtimes. |
Connect's key property for this demo: it's just HTTP. The client sends
requests to http://host/greet.v1.GreetService/<Method>; no gRPC/HTTP2
infrastructure is needed. Unary and server-streaming calls work over plain
HTTP/1.1 POST (chunked responses for streaming); client- and bidi-streaming
use the Connect streaming envelope (still just HTTP bodies).
Dependencies (pyproject.toml)
Runtime ([project] dependencies) — what server.py and client.py import:
| Dependency | Why it's here |
|---|---|
connectrpc |
Connect RPC runtime for Python. One package provides both the client (ConnectClient) and the server (ConnectASGIApplication). Pulls in protobuf-py and pyqwest (its HTTP layer) automatically. |
uvicorn |
ASGI server that runs the Connect application. |
greet |
The generated code, packaged as a uv workspace member (see below). |
Development ([dependency-groups] dev) — tools used to build and check the
project, never imported by the running code:
| Dependency | Why it's here |
|---|---|
buf-bin |
The Buf CLI, installed inside the project's venv instead of globally (via Homebrew/MacPorts). Used only for uv run buf lint / uv run buf generate. |
ty |
Astral's type checker, for static checking of server/client against the generated stubs. |
psutil |
Process/socket introspection; used by benchmark.py to count live TCP connections. |
hypercorn |
HTTP/2-capable ASGI server for server_h2.py (TLS + ALPN). |
Note: there is no gRPC dependency. Connect is a separate protocol that carries protobuf over ordinary HTTP.
Project layout
proto/greet/v1/greet.proto # the schema: greet.v1.GreetService (4 RPC modes)
buf.yaml # buf module config (STANDARD lint, FILE breaking rules)
buf.gen.yaml # buf code-generation config
gen/ # generated code (uv workspace member, see below)
pyproject.toml # makes it an installable `greet` package
greet/v1/greet_pb.py # message classes (GreetRequest, GreetResponse)
greet/v1/greet_connect.py # service stubs (ASGI app + typed client)
server.py # Connect RPC server (uvicorn/ASGI, HTTP/1.1)
server_h2.py # same app over HTTP/2 (Hypercorn, TLS)
client.py # Connect RPC client
benchmark.py # performance benchmark (codec + RPC throughput)
benchmark_h2.py # throughput benchmark over HTTP/2
certs/ # demo CA + server cert for the HTTPS/h2 server
Setup
uv sync
This creates the virtualenv and installs everything, including buf itself.
Generate code
uv run buf lint # lint the .proto schema
uv run buf generate # write gen/greet/v1/*
buf.gen.yaml uses two Buf remote plugins:
buf.build/bufbuild/py— generates the message classes using the modernprotobuf-pyruntime (greet_pb.py). This is required byconnectrpc0.12+; the classicbuf.build/protocolbuffers/pythonplugin (which targetsgoogle.protobuf) is not compatible by default.buf.build/connectrpc/py— generates the Connect service stubs (greet_connect.py): aGreetServiceASGIApplicationfor serving and a typedGreetServiceClientfor calling.
Regenerate whenever you change greet.proto.
The generated code as a package
gen/ is a uv workspace member: it has its own pyproject.toml declaring
the greet package (built with uv_build), and the root project depends on
it via tool.uv.sources (greet = { workspace = true }). This has two
benefits over hacking PYTHONPATH:
uv run server.pyjust works — the generated code is importable.- Type checkers (like
ty) resolvegreetimports as a normal installed package.
Run
Terminal 1 (server):
uv run server.py
Terminal 2 (client):
uv run client.py
Expected output:
[client] got response: Hello, world!
(uv sync installs gen/ into the venv, so no PYTHONPATH tricks needed.)
Benchmark
benchmark.py measures codec (de)serialization and RPC throughput. Start the
server, then run the benchmark:
uv run server.py # terminal 1
uv run benchmark.py # terminal 2
It measures:
- Codec — in-process serialize/deserialize of
GreetRequestat 64 / 128 / 256 / 512 / 1024 / 2048 KiB payloads, each timed to run at least 0.5 s per direction. - Unary throughput vs concurrency — 2,000
Greetround-trips at each concurrency level (1, 4, 16, 64), reporting req/s, amortized µs/req, peak TCP connections, and speedup vs concurrency 1. Connections are sampled live from the benchmark process viapsutil(a dev dependency). - Unary throughput, large payloads — 200 concurrent
Greetround-trips (concurrency 8, 2 MiB payloads), reporting MB/s of request data. - Bidi streaming throughput — 5,000 messages over a single
GreetChatstream.
Sample results (M-series MacBook, 2026-09)
The tables below are also charted inline (PNGs in docs/,
regenerate with uv run charts.py).
Compared against the sibling repos — pygrpc-demo
(gRPC + classic protobuf, Python), rust-grpc-demo
(tonic + prost, Rust), golang-grpc-demo (grpc-go +
protobuf-go, Go), golang-quic-rpc
(Connect over HTTP/3 / quic-go, Go), rust-connect-demo
(Connect + connectrpc/buffa, Rust) — all with the same schema and benchmark
methodology:
Codec (µs/op, MB/s in parentheses):
| payload | ser py | ser py-gRPC | ser Rust | ser Rust-C | ser Go | deser py | deser py-gRPC | deser Rust | deser Rust-C | deser Go |
|---|---|---|---|---|---|---|---|---|---|---|
| 64 KiB | 3.7 | 2.2 | 1.0 | 1.0 | 5.0 | 3.3 | 3.3 | 2.0 | 1.4 | 4.4 |
| 128 KiB | 5.4 | 4.2 | 1.9 | 1.9 | 10.3 | 6.4 | 6.4 | 4.8 | 2.8 | 9.3 |
| 256 KiB | 10.7 | 7.2 | 3.8 | 3.8 | 21.1 | 12.5 | 12.8 | 8.7 | 6.8 | 18.9 |
| 512 KiB | 66.9 | 23.4 | 7.4 | 7.4 | 44.8 | 24.4 | 25.0 | 16.8 | 13.1 | 39.9 |
| 1024 KiB | 129.6 | 80.9 | 14.8 | 14.8 | 65.1 | 48.1 | 49.4 | 34.6 | 25.7 | 71.1 |
| 2048 KiB | 352.8 | 208.2 | 99.0 | 100.1 | 104.3 | 159.9 | 158.5 | 136.9 | 122.5 | 91.0 |
(µs/op; py = this repo's protobuf-py Rust ext, py-gRPC = classic google.protobuf C/upb, Rust = prost, Rust-C = buffa (connect-rust), Go = protobuf-go. The two Rust codecs are on par on serialize; buffa wins deserialize (zero-copy views). Both drop sharply on 2 MiB serialize (~21 GB/s from ~70 GB/s).)
Unary throughput vs concurrency (1 KiB payload). The conns column is
the fingerprint of each stack's concurrency mechanism — HTTP/1.1 has no
multiplexing, so N in-flight requests require N sockets; the HTTP/2
clients each hold a single connection by construction (one channel — a
client default, not a protocol cap). See the mechanics section below:
| concurrency | Connect req/s | conns | py-gRPC req/s | conns | Rust req/s | conns | Rust-Connect req/s | conns | Go req/s | conns |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | 4,871 | 1 | 4,446 | 1 | 20,898 | 1* | 20,844 | 1* | 19,583 | 1 |
| 4 | 11,658 | 4 | 13,563 | 1 | 36,672 | 1* | 42,111 | 4* | 36,698 | 1 |
| 16 | 12,769 | 16 | 27,139 | 1 | 40,516 | 1* | 91,941 | 16* | 63,796 | 1 |
| 64 | 13,383 | 64 | 27,950 | 1 | 40,497 | 1* | 117,887 | 64* | 72,394 | 1 |
(*Rust repos count via netstat, which shows each loopback connection from both endpoints and pool churn — Rust-gRPC's consistent "2" is one connection; Rust-Connect's counts track its HTTP/1.1 sockets (2 per connection) plus hyper pool churn. Both Go columns count via lsof/psutil, one row per connection.) Speedups vs c=1: Go 3.7×, py-gRPC 6.3×, Rust 1.9×, Connect 2.7×. Both Go and Rust are multi-threaded by default — Go schedules goroutines across GOMAXPROCS (= core count) OS threads, tonic runs on tokio's multi-threaded work-stealing runtime (workers = core count); neither is thread-per-core. Rust's low single-channel scaling is per-connection serialization, not CPU: spreading the same load over 8 tonic channels reaches ~134k req/s — see rust-grpc-demo and the chart below.)
Large payloads & streaming:
| Benchmark | Connect (py) | gRPC (py) | gRPC (Rust) | gRPC (Go) | Connect h3 (Go) | Connect (Rust) |
|---|---|---|---|---|---|---|
| unary, 2 MiB payload (c=8) | 189 req/s (397 MB/s) | 513 req/s (1,076 MB/s) | 626 req/s (1,313 MB/s) | 922 req/s (1,933 MB/s) | 104 req/s (218 MB/s) | 937 req/s (1,966 MB/s) |
| bidi streaming (1 KiB) | 18.2k msg/s | 13.7k msg/s | 572k msg/s | 398k msg/s | 91k msg/s | 90.5k msg/s |
Takeaways:
- Codec: prost (Rust) is the fastest at nearly every size — roughly 2–9× faster than the Python runtimes and 2–4× faster than protobuf-go. Deserialize is a wash between the two Python runtimes; classic protobuf (C/upb) serializes large messages ~1.5–2× faster than protobuf-py's Rust extension.
- Compiled-language gRPC dominates throughput: Go peaks at 72k req/s on a single connection (3.7× scaling), Rust at 40k on a single channel (1.9×) — but ~134k req/s when spread over 8 channels, so Rust's plateau is per-connection serialization rather than language speed. Python gRPC reaches ~28k, Connect-over-HTTP/1.1 ~13k. All gRPC stacks hold one TCP connection per channel at every level; Connect's HTTP/1.1 needs one socket per in-flight request (see the mechanics table below).
- The same protocol, implemented in Rust, is another 9× faster: Connect over HTTP/1.1 with connect-rust reaches 118k req/s at c=64 and ~2 GB/s at 2 MiB — the fastest stack in the family on large payloads — while still paying the HTTP/1.1 socket-per-request fingerprint.
- Bidi streaming amplifies the language gap: Rust 572k msg/s and Go 398k msg/s vs ~14–18k for both Python stacks (~30–40×); Connect-rust at 90.5k msg/s sits with Go's HTTP/3 stack. Per-message overhead is dominated by the RPC stack, not the wire.
Numbers are machine- and load-dependent; treat them as indicative, not absolute.
Connect over HTTP/2
Yes — Connect is a protocol over HTTP, not tied to HTTP/1.1. server_h2.py
serves the same ASGI application through Hypercorn with TLS/ALPN, and the
client needs only pyqwest.HTTPTransport(http_version=HTTPVersion.HTTP2)
(benchmark_h2.py shows how). Verified via ALPN negotiation: h2.
uv run server_h2.py # terminal 1 (https://127.0.0.1:8443)
uv run benchmark_h2.py # terminal 2
Results (same methodology as the HTTP/1.1 sweep):
| concurrency | req/s (HTTP/2, Hypercorn) | TCP conns | req/s (HTTP/1.1, uvicorn) | TCP conns |
|---|---|---|---|---|
| 1 | 2,616 | 1 | 4,871 | 1 |
| 4 | 5,502 | 1 | 11,658 | 4 |
| 16 | 7,188 | 1 | 12,769 | 16 |
| 64 | 7,058 | 1 | 13,383 | 64 |
| Benchmark | HTTP/2 (Hypercorn) | HTTP/1.1 (uvicorn) | gRPC (C core) |
|---|---|---|---|
| unary, 2 MiB payload (c=8) | 81 req/s (170 MB/s) | 189 req/s (397 MB/s) | 513 req/s (1,076 MB/s) |
| bidi streaming (1 KiB) | 11.2k msg/s | 18.2k msg/s | 13.7k msg/s |
The mechanics change as theory predicts — one connection at every concurrency level — but throughput drops ~35–50% versus HTTP/1.1 here. The reason is implementation, not protocol: Hypercorn's HTTP/2 stack is pure Python (h2 frame parsing per request), while uvicorn's HTTP/1.1 path is leaner, and gRPC's HTTP/2 lives in C. TLS handshake overhead adds a bit on top (amortized over the single connection, so it's minor).
Two practical notes discovered along the way:
- Hypercorn closes a connection after
keep_alive_max_requests = 1000requests by default;server_h2.pydisables that so sustained benchmarks don't race the server's connection close (they fail with "broken pipe" otherwise). h2_max_concurrent_streamsdefaults to 100 — raised inserver_h2.pyso high concurrency isn't artificially capped.
The demo certs in certs/ are self-signed (CA + leaf, 30-day validity),
generated with openssl; regenerate them if expired.
HTTP/1.1 vs HTTP/2 mechanics
The concurrency sweep surfaces the protocol difference that plain req/s numbers hide. Read the connection column as the fingerprint of each protocol's concurrency mechanism — not as a cost score:
- HTTP/1.1 has no multiplexing. With pipelining effectively dead (no mainstream client enables it), one connection carries exactly one in-flight request — so N concurrent requests require N sockets. The parallel connections are not an anti-head-of-line-blocking optimization we chose; they are the only way to get concurrency at all.
- Our HTTP/2 clients hold exactly one connection by construction: each benchmark drives a single channel/client, and one channel means one connection. That is a client default, not a protocol cap — HTTP/2 permits multiple connections (browsers open several per host, and gRPC deployments sometimes shard across channels on purpose).
The head-of-line-blocking tradeoff is likewise the opposite of the usual telling: with parallel HTTP/1.1 connections, each request owns its socket, so a lost packet stalls only that one request — the connection-per-request model eliminates cross-request HOL (the HOL problem HTTP/1.1 actually has is pipelining HOL, which nobody uses). HTTP/2's single shared connection buys multiplexing at the price of TCP-level HOL: one lost segment stalls every in-flight stream. Fixing that is precisely what QUIC/HTTP/3 exists for.
| Mechanism | Connect (pyqwest, HTTP/1.1) | gRPC (HTTP/2) |
|---|---|---|
| Concurrency mechanism | parallel connections (the only option: no multiplexing, pipelining unused) | streams multiplexed over one connection |
| Connections at concurrency N | N (measured: 1→1, 4→4, 16→16, 64→64) | 1 by construction (single channel per client; the protocol allows more) |
| Scaling observed | sockets scale linearly, throughput plateaus ~2.7× — bottleneck is Python per-request work, not sockets | streams scale to ~6.3× (py), 3.7× (Go) |
| Head-of-line blocking | none across requests — each request has its own socket; a lost packet stalls only its request | TCP-level: one lost packet stalls every stream on the shared connection |
| Cost of the mechanism | file descriptors, ephemeral ports, TLS handshakes (off-loopback), LB connection tracking | shared flow-control window; single point of loss |
| Header encoding | plain text on every request (no reuse) | HPACK with shared dynamic table — repeated headers cost a few bytes |
| Streaming framing | Connect envelope (5-byte prefix per message) inside a chunked HTTP/1.1 body | length-prefixed frames inside HTTP/2 DATA frames |
| Server-side cost per call | a fresh HTTP/1.1 request/response cycle in Python (uvicorn + connectrpc) | stream handling in gRPC's C core, callbacks into Python |
Practical implications:
- HTTP/1.1's connection-per-request model means Connect's client opens (and keeps pooled — see the large-payload test note) one socket per unit of desired concurrency. That's nearly free on loopback — Connect's plateau is Python per-request work, not socket overhead — but a real cost at scale: file descriptors, ephemeral ports, server accept loops, and load balancer connection tracking.
- HTTP/2's multiplexing makes concurrency "free" connection-wise, but concentrates risk: a dropped TCP segment stalls all in-flight streams (TCP head-of-line blocking), and both directions share one flow-control window. Workarounds (multiple channels/connections) reintroduce the connection management HTTP/2 was meant to remove.
- Note that Connect is not wedded to HTTP/1.1 — see "Connect over HTTP/2" above: the same app multiplexes over one connection, but throughput depends on the HTTP implementation (pure-Python Hypercorn h2 is slower than uvicorn's h11, and both are slower than gRPC's C-core h2).
- The next step on the protocol ladder is QUIC/HTTP/3, which removes the
TCP head-of-line blocking above (per-stream loss recovery over UDP) —
measured in
golang-quic-rpc: Connect + quic-go holds one QUIC connection at every concurrency level, peaks at 38k req/s, but pays user-space packet costs on loopback (218 MB/s at 2 MiB payloads vs 1.9 GB/s for gRPC-Go over kernel TCP). That loopback picture is representative of perfect network conditions such as intra-datacenter calls, where HTTP/2 over kernel-optimized TCP will be the faster choice; under real (lossy, mobile, long-distance) network conditions QUIC's per-stream loss recovery avoids stalling every multiplexed stream on one lost segment, and it tends to come out ahead.
Type checking
The generated Connect code is fully typed — GreetServiceClient.greet takes
and returns real message types, and the server implements the generated
GreetService protocol. So static type checking works well here, using
Astral's ty (a dev dependency):
uv run ty check
This checks server.py and client.py and the generated stubs in gen/.
It's strict enough to catch real wiring mistakes — for example, it flags a
service implementation whose method signature doesn't satisfy the generated
GreetService protocol (missing parameters, wrong types, etc.) before you
ever run the code.
How the code works
Schema (proto/greet/v1/greet.proto) — demonstrates all four RPC kinds:
service GreetService {
// Unary: one request, one response.
rpc Greet(GreetRequest) returns (GreetResponse);
// Server streaming: one request, a stream of responses.
rpc GreetStream(GreetStreamRequest) returns (stream GreetStreamResponse);
// Client streaming: a stream of requests, one response.
rpc GreetCount(stream GreetCountRequest) returns (GreetCountResponse);
// Bidirectional streaming: streams both ways.
rpc GreetChat(stream GreetChatRequest) returns (stream GreetChatResponse);
}
Note: Buf's STANDARD lint rules require each RPC to have dedicated request /
response messages (hence GreetStreamRequest etc. rather than reusing
GreetRequest).
Server (server.py): implements all four methods and wraps them in the
generated GreetServiceASGIApplication, served by uvicorn on 127.0.0.1:8080.
greet— plain async method:async def greet(request, ctx) -> GreetResponsegreet_stream— async generator yielding multiple responsesgreet_count— takes anAsyncIteratorof requests, returns one responsegreet_chat— async generator consuming and yielding a stream
Client (client.py): instantiates the generated GreetServiceClient with
just the base URL (http://127.0.0.1:8080 — the protocol appends the full
procedure path itself) and runs one demo per RPC mode:
--- unary: Greet ---
[client] Hello, world!
--- server streaming: GreetStream ---
[client] Hello #1, streamer!
[client] Hello #2, streamer!
[client] Hello #3, streamer!
--- client streaming: GreetCount ---
[client] server counted 3 requests
--- bidirectional streaming: GreetChat ---
[client] Hi, alice!
[client] Hi, bob!
[client] Hi, carol!
Because unary Connect is plain HTTP with a JSON option, this works too:
curl -X POST -H 'content-type: application/json' \
-d '{"name": "world"}' \
http://127.0.0.1:8080/greet.v1.GreetService/Greet
# {"greeting":"Hello, world!"}
(The Connect protocol supports both binary protobuf and JSON bodies on unary
calls — the server negotiates via the request's Content-Type, so the same
endpoint serves both. Streaming RPCs use a length-prefixed envelope over the
HTTP body, so those are protobuf-only in practice.)



