How many Postgres connections is too many?

We swept a read-mostly workload from 5 to 400 concurrent connections against a fixed CPU allocation on Postgres 16. Throughput peaks at about four times the core count. Past that, throughput degrades gently and latency degrades catastrophically — and the number that actually justifies a pooler is 2.

Many callers, one meter, and the database behind it

On four cores, Postgres 16 peaked at 16 concurrent connections — four times the core count — and 400 connections delivered 48% of that peak throughput at 52 times the latency. The folklore fix for FATAL: sorry, too many clients already is to raise max_connections and move on. It does make the error go away. It also converts a throughput problem into a latency problem, and the measurements below say that is a bad trade.

Two results surprised us. Throughput did not collapse past the peak — it sagged gently, which is exactly why the folklore survives. And pgbouncer made an already-pooled workload measurably slower.

The short answer

  • Postgres 16 throughput peaked at four connections per core. On a 4-core allocation the peak was 61,304 transactions/sec at 16 connections; on a 2-core allocation it was 34,939/sec at 8 connections. Same multiple, both times.
  • Past the peak, throughput falls slowly and latency rises fast. Going from 16 to 400 connections cost 52% of throughput but multiplied mean latency by 52x, from 0.261 ms to 13.626 ms. Latency is the thing oversubscription destroys.
  • An idle Postgres connection costs about 1.9 MiB of real memory — measured as the container's cgroup usage rising from 1.169 GiB at 10 connections to 1.528 GiB at 200. Summing per-process RSS says 6.9 GB, which is nonsense: the container is capped at 4 GB.
  • A query on a connection you already hold takes 0.052 ms; the same query on a freshly opened connection takes 3.09 ms. That is 59x, and it is the entire argument for a connection pool.
  • SCRAM authentication is 73% of that connection cost — 2.09 ms of the 2.86 ms handshake. It is also why putting pgbouncer in front of a connect-per-request app only recovered 1.3x, instead of the 6.5x it recovered when authentication was free.

What was measured, and on what

Apple M3, 8 cores, 16 GB RAM, macOS 26.4.1. Docker Desktop 4.75.0, engine 29.5.2, with 8 CPUs and 7.75 GiB visible to the Linux VM. Postgres 16.15 (aarch64, Debian build) in a container limited to --cpus=4 (and --cpus=2 for the core-count comparison), --memory=4g, --shm-size=1g, with max_connections=600, shared_buffers=1GB and work_mem=4MB.

The workload is pgbench at scale 50 — a 755 MB database, prewarmed so it sits entirely in shared buffers — driven as -b select-only@9 -b simple-update@1: 90% single-row primary-key SELECTs, 10% single-row UPDATEs. No disk reads, so what we are measuring is CPU, locks and the WAL, not storage.

pgbench runs in a separate container with its own 4-CPU quota, so client and server never share a CPU budget. During runs Postgres sat at 391-417% CPU — its entire quota — while pgbench sat at 144-152%. The server is the bottleneck, which is the only condition under which this experiment means anything.

These are indicative figures from a laptop running Docker, not a tuned server benchmark, and noisier than we would like: macOS background work shifted absolute throughput by up to 40% between measurement windows. Every sweep below therefore runs three passes, ordering client counts ascending, descending, then ascending, so drift cannot masquerade as a trend; we report the median and the full range. Port the shape, not the absolute numbers.

Where does Postgres throughput actually peak?

Four CPUs, three passes of 15 seconds at each client count.

Concurrent connections tps (median) tps (range) Mean latency % of peak
5 31,213 29,377–39,886 0.160 ms 51%
10 50,187 46,361–55,097 0.199 ms 82%
16 61,304 55,978–61,362 0.261 ms 100%
25 54,566 50,843–59,396 0.458 ms 89%
50 52,541 49,204–58,669 0.952 ms 86%
100 50,770 40,160–51,101 1.970 ms 83%
200 39,893 34,801–39,993 5.013 ms 65%
400 29,355 25,256–29,535 13.626 ms 48%

The peak is at 16 connections on 4 cores. Now look at the decline. At 100 connections — six times oversubscribed — you still have 83% of peak throughput. That is the trap: if you only watch transactions per second, oversubscription looks nearly free, and the folklore that max_connections is a harmless dial looks correct.

The latency column tells the real story. Between 16 and 400 connections throughput dropped by half while mean latency went up 52-fold. Twenty-five times as many connections bought nothing and cost every user thirteen milliseconds.

Throughput declines gently past the peak while latency rises steeply

Is four-per-core a coincidence of this box? We reran 2-core and 4-core allocations interleaved inside a single time window, so host drift hits both equally. Medians of three 12-second runs:

Concurrent connections 2 cores (tps) 4 cores (tps)
2 29,621 28,921
4 33,054 33,256
8 34,939 48,349
16 32,724 61,475

Two cores peak at 8 connections. Four cores peak at 16, and the 61,475 here matches the 61,304 from the separate sweep above to within 0.3% — the shape reproduces even when the absolute level does not. Both peaks are at 4x the core count.

Why does adding connections make it slower?

Because past saturation, the extra backends are not computing. They are queuing for a lock. Sampling pg_stat_activity for active client backends during two runs on 4 cores:

Wait state 25 connections (44,350 tps) 400 connections (25,488 tps)
LWLock:WALWrite 62.7% 74.1%
LWLock:WALInsert 10.5%
IPC:ProcArrayGroupUpdate 0.3% 7.0%
Client:ClientRead 16.9% 4.1%
CPU (running) 12.2% 3.4%
IO:WALSync 7.5%

A dash means the state did not reach the eight most frequent in that run. Samples: 2,354 at 25 connections, 24,626 at 400.

The share of time backends spend actually running fell from 12.2% to 3.4%. Four hundred backends contend on one write-ahead log insertion lock, and the ProcArrayGroupUpdate line — twenty times its share at 25 connections — is backends queuing to announce that their transaction has finished. Every connection you add makes the snapshot and commit bookkeeping that every other connection depends on a little more expensive. That is why the curve bends down rather than flattening out, and it is the same wall a Postgres job queue hits at 16 workers.

How much memory does an idle Postgres connection use?

This is where the standard answer — "look at RSS" — is actively wrong. Ten and 200 idle connections, each having run one small query first so its catalog caches are populated, measured twice with near-identical results:

Metric 10 connections 200 connections Per connection
Sum of backend VmRSS 342 MB 6,906 MB 35.1 MB
of which RssShmem (shared buffers) 221 MB 4,480 MB 22.9 MB
of which RssFile (the binary) 90 MB 1,812 MB 9.3 MB
of which RssAnon (private) 30 MB 612 MB 3.1 MB
Container memory (cgroup) 1.169 GiB 1.528 GiB 1.9 MiB

Adding up per-process RSS gives 6.9 GB inside a container hard-limited to 4 GB, which should be enough to tell you the sum is meaningless. Most of each backend's RSS is shared_buffers pages and the Postgres binary — counted once per process, existing once in total.

The honest figure is the cgroup delta: 368 MiB for 190 additional connections, 1.9 MiB each. Even RssAnon overstates it at 3.1 MB, because forked backends share copy-on-write pages with the postmaster.

So 500 idle connections is under a gigabyte — another reason the "just raise it" advice survives contact with production. The caveat is that this is idle cost. work_mem is allocated per sort or hash node, not per connection, so a plan with four hash joins can claim 4 × work_mem on each connection at once. At work_mem=4MB and 400 connections that is a 6.4 GB tail risk on a 4 GB box, and it is why reading the plan matters.

How expensive is opening a connection?

This is the number that justifies a pooler. One client, 12-second runs, three repetitions, all values within 0.5% of each other:

What the client does Connection time Transaction latency tps
Reuses one connection 0.052–0.053 ms 19,013–19,350
Opens a new one each time (SCRAM) 2.861–2.867 ms 3.083–3.089 ms 324
Opens a new one each time (trust) 0.768–0.773 ms 0.982–0.988 ms 1,013–1,018

A SELECT against a prewarmed table takes 0.052 ms. Getting a connection to run it on takes 2.86 ms — 55 times longer than the work itself. At 10 concurrent clients the gap is wider still: 157,078–162,503 tps with persistent connections against 830–882 tps when each transaction reconnects. A factor of 184.

The third row is the part nobody mentions. Switch pg_hba.conf to trust and the handshake drops from 2.86 ms to 0.77 ms. SCRAM-SHA-256 runs 4,096 rounds of PBKDF2 by design, and that deliberate slowness is 2.09 ms — 73% of the cost of opening a connection. It is doing its job; you just do not want to pay for it on every HTTP request.

We also hit this by accident: at roughly 2,000 connections per second the client container ran out of ephemeral ports and pgbench died with Cannot assign requested address. Sockets in TIME_WAIT accumulate faster than the kernel retires them. Churn breaks the client before it breaks Postgres.

Does pgbouncer make it faster?

Not if your application already holds its connections. pgbouncer 1.25.2 in transaction mode, default_pool_size=25, direct and pooled runs executed back to back at each client count so drift affects both:

Clients Direct (pass 1 / pass 2) Via pgbouncer (pass 1 / pass 2)
50 79,675 / 46,767 52,484 / 31,843
100 71,503 / 43,565 56,294 / 36,037
200 59,814 / 43,244 51,633 / 43,851
400 34,636 / 34,120 34,074 / 43,449

At 50 and 100 clients pgbouncer cost 30-34% of throughput in both passes. It breaks even around 200. We expected a clear win at high client counts and did not get one: an extra process hop on every statement is not free, and pgbouncer is single-threaded. At 400 clients it sat at 98.4% of one core while Postgres dropped to 318% — the pooler had become the bottleneck, capping the system at about 44,450 tps.

Where it wins is churn. Fifty clients reconnecting on every transaction:

Auth method Direct (tps) Via pgbouncer (tps) Gain
SCRAM-SHA-256 683 / 753 / 729 824 / 950 / 986 1.3x
trust 1,935 / 1,979 / 1,992 9,098 / 12,761 / 13,132 6.5x

The gap between those rows is the finding. pgbouncer removes the cost of forking a Postgres backend. It cannot remove the cost of authenticating the client to pgbouncer — that handshake still happens, still runs SCRAM, still costs 2 ms. With authentication out of the picture the pooler is worth 6.5x; with SCRAM in front of it, 1.3x.

The admission-control benefit is unaffected by any of this. With 400 application clients connected, pg_stat_activity showed 26 client backends and pgbouncer's own resident memory was 9.2 MB.

So what should max_connections be?

The framing is wrong, and that is most of the problem. max_connections is not a target, it is a fuse. The number that determines your performance is the pool size — how many connections your application actually keeps busy. For a read-mostly workload on N cores, from these measurements:

  • Set the pool to about 4 x N. That was the measured peak at both 2 and 4 cores. Anything from 2x N to 6x N is within 10-15% of peak, so there is no need to be precise — bias low, because latency is what you lose by guessing high.
  • Set max_connections to a few times the pool size, sized by memory, not by hope. At 1.9 MiB per idle connection plus your work_mem tail, 500 is under a gigabyte on this box. It exists so a migration, a psql session or a replication slot can always get in.
  • If you are raising max_connections because you keep hitting it, you have a pooling bug, not a capacity limit. Something is opening connections it does not need, or not returning them.
  • Add a pooler for the reason it helps you. Connection churn: measurably yes. Admission control at 400+ clients: yes, 26 backends instead of 400. Making an already-pooled app faster: no — it cost us 30% at 50 clients.

The one number worth memorising is 3.09 ms against 0.052 ms. Everything else here is a curve with a gentle slope. That one is a cliff.

Check it yourself

Four blocks, about four minutes. They reproduce the peak, the latency explosion, the connection cost and the memory cost.

docker network create cite-conn-net
docker run --rm -d --name cite-conn-pg --network cite-conn-net --cpus=4 --shm-size=1g \
  -e POSTGRES_PASSWORD=demo -p 55581:5432 postgres:16 \
  -c max_connections=600 -c shared_buffers=1GB
docker run --rm -d --name cite-conn-bench --network cite-conn-net --cpus=4 \
  --sysctl net.ipv4.ip_local_port_range="10000 65535" \
  --entrypoint sleep postgres:16 infinity
until docker exec cite-conn-pg pg_isready -U postgres >/dev/null 2>&1; do sleep 1; done
docker exec -e PGPASSWORD=demo cite-conn-bench pgbench -h cite-conn-pg -U postgres -i -s 50 -q postgres

1. Find the peak. Ours: 38,952 tps at 5 clients, 72,254 at 16, 65,068 at 50, 33,239 at 400 — and latency 0.128 ms, 0.221 ms, 0.768 ms, 12.034 ms.

for c in 5 16 50 400; do
  printf "%4s clients: " $c
  docker exec -e PGPASSWORD=demo cite-conn-bench pgbench -h cite-conn-pg -U postgres -n \
    -b select-only@9 -b simple-update@1 -c $c -j 4 -T 15 postgres 2>&1 \
    | grep -E '^tps|^latency average' | tr '\n' ' '; echo
done

2. Price a connection. Ours: 0.054 ms to run the query on a connection you already have, 2.877 ms to get a new one.

docker exec -e PGPASSWORD=demo cite-conn-bench pgbench -h cite-conn-pg -U postgres -n -S \
  -c 1 -j 1 -T 10 postgres 2>&1 | grep -E '^tps|^latency average'
docker exec -e PGPASSWORD=demo cite-conn-bench pgbench -h cite-conn-pg -U postgres -n -S -C \
  -c 1 -j 1 -T 10 postgres 2>&1 | grep -E '^tps|^latency average|^average connection'

3. Price idle connections. Ours: 1003 MiB at 10, 1.318 GiB at 200 — 1.8 MiB each.

for n in 10 200; do
  for i in $(seq 1 $n); do
    docker exec -e PGPASSWORD=demo cite-conn-bench psql -h cite-conn-pg -U postgres -qtA \
      -c "SELECT count(*) FROM pgbench_accounts WHERE aid < 2000" \
      -c "SELECT pg_sleep(40)" postgres >/dev/null 2>&1 &
  done
  sleep 18
  echo "$n idle connections: $(docker stats --no-stream --format '{{.MemUsage}}' cite-conn-pg)"
  wait
done

docker rm -f cite-conn-pg cite-conn-bench && docker network rm cite-conn-net

Your absolute numbers should differ from ours. The two that ought to survive the move to your hardware are the ratio in step 2 — a new connection costing tens of times more than the query it runs — and the peak in step 1 sitting at roughly four times your core count — for this workload.

A caveat worth stating, because it changes the number. This benchmark runs a 9:1 read/update mix. Re-running it as pure select-only on two dedicated cores, with the load generator on separate CPUs, the peak moves down to 4 clients — twice the core count, not four times:

Clients tps (select-only, 2 cores)
2 37,488
4 72,570
8 64,422
16 62,703
32 57,957

Updates wait on I/O, and a client waiting on I/O is not holding a core — so a write-mixed workload sustains more concurrency before it peaks. A pure in-memory read workload saturates CPU sooner and peaks earlier.

So the honest rule is a small multiple of your core count — roughly 2x to 4x, depending on how much your workload waits — and emphatically not the hundreds that max_connections lets you configure. Measure your own mix; the shape is universal, the multiplier is not.