dbstack

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
Group by
0 1,000 2,000 3,000 4,000 4,142 399 1,482 c = 10 3,339 1,441 3,673 c = 50 4,138 2,316 3,876 c = 100 916 2,257 4,359 c = 200 CONCURRENT CLIENTS
0 1,000 2,000 3,000 4,000 4,142 10 3,339 50 4,138 100 916 200 Direct 399 10 1,441 50 2,316 100 2,257 200 PgBouncer 1,482 10 3,673 50 3,876 100 4,359 200 Pgcat POOLER · BARS BY CLIENT COUNT

§ 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:

  1. How does throughput change as concurrent clients climb from 10 to 200?
  2. Does direct Postgres still keep up at high concurrency?
  3. Where exactly does pooling start to pay off?

Setup

A single docker compose stack with four services on one bridge network:

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:

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:

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