Index The Numbers
§ The Numbers
2026-05-19
PgBouncer vs Pgcat under pgbench.
How two open-source Postgres connection poolers handle 10–200 concurrent clients on the same hardware.
pgbench TPC-B at scale 10, 4 client counts × 3 repeats × 30s, against Postgres 16 in Docker.
Featured · PgBouncer @ 100 clients
- Throughput
- 2,316
- tps · median
- Avg latency
- 43.18
- ms · per query
- Connect time
- 73.9
- ms · initial
- Sample
- 12
- data points
§ Throughput
tps · higher is better
- Direct peak 4,142
- PgBouncer peak 2,316
- Pgcat peak 4,359
§ The Table
Medians of 3 runs · 30 s each
| Pooler | c = 10 | c = 50 | c = 100 | c = 200 |
|---|---|---|---|---|
| Direct | 4,142 2.41 12.42 | 3,339 14.98 129.23 | 4,138 24.16 94.94 | 916 218.30 269.93 |
| PgBouncer | 399 25.06 14.07 | 1,441 34.69 71.45 | 2,316 43.18 73.93 | 2,257 88.62 137.21 |
| Pgcat | 1,482 6.75 9.42 | 3,673 13.62 23.05 | 3,876 25.80 59.00 | 4,359 45.88 111.26 |
Generated 2026-05-19T04:03:37.673Z. 12 data points · each cell the median of 3 runs.
Why this benchmark
Every Postgres app that grows past a single instance hits the same wall: too many clients, not enough Postgres backend processes. The standard answer is a connection pooler. The two most-deployed open-source options are PgBouncer (C, single-threaded, around since 2007) and Pgcat (Rust, multi-threaded, from the PostgresML team, around since 2022).
Production users have plenty of opinions about which is faster, but most of those opinions are written in marketing copy or in forum posts that don’t disclose their setup. This benchmark uses pgbench — Postgres’s standard load generator — to put concrete numbers on the question, on the same hardware, with the same workload, with the same Postgres instance behind each pooler.
The headline questions:
- How does throughput change as concurrent clients climb from 10 to 200?
- Does direct Postgres still keep up at high concurrency?
- Where exactly does pooling start to pay off?
Setup
A single docker compose stack with four services on one bridge network:
- Postgres 16 (the upstream database) with
max_connections=500,shared_buffers=256MB, trust auth. - PgBouncer (
edoburu/pgbouncer:v1.25.1-p0), transaction pooling, default pool size 25, max client connections 1000. - Pgcat (
ghcr.io/postgresml/pgcat:latest), transaction pooling, pool size 25, 4 worker threads. - A pgbench client running inside the Postgres container so the network cost is the same Docker bridge in every case.
pgbench -i -s 10 loads about 150 MB of data (1M accounts, 100K tellers,
10K branches). Each measurement is pgbench -M simple -j 8 -T 30 against the
relevant endpoint, run three times per (pooler, client count). Five-second
warmups precede each combination. Medians are reported in the table above;
min/max are in the raw JSON.
The host is Docker Desktop on macOS, Apple Silicon. This matters: Docker on Mac runs inside a VM, so absolute numbers are lower than a Linux baremetal server would deliver. The relative comparison between poolers is still valid because they all share the same penalty.
What the numbers say
Direct Postgres collapses at 200 clients
Up to 100 concurrent clients, direct Postgres holds 4,000+ TPS. At 200 clients, throughput crashes to ~900 TPS and average latency jumps from 24 ms to 218 ms. This is the classic “connection storm” failure mode: each client gets its own Postgres backend process, context-switching dominates, and the database spends more time scheduling than serving queries.
This is the entire reason connection poolers exist. The number is not a criticism of Postgres — it’s the constraint Postgres assumes you’ll handle with a pooler.
Pgcat scales gracefully, even past direct
Pgcat’s curve is the cleanest: 1,482 TPS at 10 clients (overhead from going through the pooler), 3,673 at 50, 3,876 at 100, and 4,359 at 200. At the worst point (200 clients), Pgcat is delivering more throughput than direct Postgres delivered at any client count.
The mechanism is straightforward. With transaction pooling and pool size 25, the 200 concurrent client connections multiplex onto 25 backend connections. The Postgres server only sees a workload it can handle; Pgcat absorbs the queueing. Pgcat’s multi-threaded core (4 worker threads here) handles the fan-out across cores.
PgBouncer is unexpectedly soft
The PgBouncer numbers are weaker than the conventional wisdom predicts: 399 TPS at c=10, climbing to 2,316 at c=100, and basically flat at 2,257 at c=200. At low client counts, PgBouncer should be within a few percent of direct Postgres. Instead it lands at roughly 10× slower at c=10 and roughly half the throughput of Pgcat across the board.
Variance is also high (PgBouncer c=50 ranged from 182 to 1,716 TPS across three runs). This combination — low absolute throughput, high variance — strongly suggests a configuration interaction rather than a fundamental PgBouncer ceiling. PgBouncer is single-threaded and very sensitive to its own scheduling on the host: under Docker on macOS, that single thread is contending with the VM hypervisor and likely the cause of both the floor and the swing.
We are treating this as the most interesting open question from the run. A follow-up benchmark on Linux baremetal — with the same pool size and config — will isolate whether the gap is fundamental or a host-level interaction.
When each one wins
Reading the table, the practical decision rule is:
- Up to ~50 concurrent clients on a properly sized Postgres: direct is fine. Adding a pooler costs throughput here. The pooler exists to handle the cases where you can’t predict the connection count.
- 50–100 concurrent clients: start adding a pooler. Pgcat in this run scaled smoothly across this range. PgBouncer probably will too on Linux baremetal with tuned settings; expect to spend time on the config.
- Beyond 100 concurrent clients: a pooler is no longer optional. Direct Postgres at 200 clients is essentially unavailable in this run.
Note that “concurrent clients” here is active concurrent queries, not idle connections. A Postgres database with 1,000 idle connections doesn’t hit this wall; the wall is reached when 1,000 connections all want to run queries simultaneously.
Caveats and known limits
This is the first benchmark in a series. Limits to be aware of:
- Apple Silicon Docker affects absolute numbers; relative comparisons should hold. A Linux baremetal re-run is on the roadmap.
- Default pool size of 25 is sensible but not universal. Production pools often run 50–100 backend connections per pooler instance.
- pgbench simple mode runs the TPC-B-ish workload. Real apps are different. Read-heavy or analytical workloads would shift the results.
- 3 runs per cell is enough to see the shape of the curve but not enough to bound the tails. The min/max columns in the raw JSON tell you the range.
- Supavisor is missing. We tried to include Supabase’s Elixir-based pooler, but its tenant-metadata initialization didn’t fit the flat-config Docker pattern of the other two. Supavisor gets its own benchmark in a follow-up post.
- No P95/P99 latency. pgbench reports averages by default. A follow-up
using
--logand percentile post-processing is planned.
Reproducing
The compose file, pooler configs, runner, and summarizer all live in
bench/postgres-poolers/ in the dbstack repository. The whole run is:
cd bench/postgres-poolers
docker compose up -d postgres pgbouncer pgcat
docker exec dbstack-bench-pg pgbench -i -s 10 -U bench -d bench
./run.sh
node summarize.mjs
The summarizer rewrites src/data/benchmarks/postgres-poolers.json from the
latest run, so a rerun and rebuild publishes fresh numbers automatically.
§ Contenders
- direct
- PgBouncer
- Pgcat