Software

PgBouncer: 19x faster connects, but half the throughput

By · Thu Oct 22 2026 · 11 min read · 0 views

View as a Web Story

Software#performance#postgresql#database#pgbouncer#connection-pooling

Bar chart comparing Postgres transactions per second with a new connection per transaction, direct and through PgBouncer

PgBouncer made connect-per-request traffic 19x faster in our Postgres 18 lab, and it also cut the throughput of 200 busy clients roughly in half. Both results are true at once. A pooler removes the cost of opening a connection, and it adds a hop that one process must carry. Which effect wins depends on how your app connects.

This post gives the measurements, the reason pool size changed little, the error that tells you a pooler is overdue, and a rule for when to add one.

What is PgBouncer, and what does a pooler do?

PgBouncer is a lightweight connection pooler for PostgreSQL that sits between your application and the database and reuses a small set of server connections for many client connections. A connection pooler is a proxy that keeps database connections open so that new requests skip the cost of connecting.

Postgres starts a separate operating-system process for every connection. Opening one costs a TCP handshake, often TLS, authentication and a process fork. Keeping hundreds of them open costs memory and scheduling time.

PgBouncer has three pool modes. The PgBouncer configuration reference lists them: session mode releases a server connection when the client disconnects and is the default, transaction mode releases it after each transaction, and statement mode releases it after each statement. Transaction mode is the one that lets 1,000 clients share 20 server connections, so it is the mode this post tests.

PlanetScale's guide to scaling Postgres connections with PgBouncer and EnterpriseDB's article on setting max_connections explain why you want one. What they do not give is a measured curve on one machine, with direct and pooled runs side by side. That is what we built.

How we measured it

The test setup and methodology are listed below.

The test setup was PostgreSQL 18.1 and PgBouncer 1.26.1 on an Apple M2 laptop with 8 cores and 8 GB of RAM. We used pgbench, the benchmark tool bundled with Postgres, at scale 20, which creates a 2-million-row accounts table.

We ran two workloads:

  1. Select-only (pgbench -S). One indexed read per transaction, the shape of a typical page load.
  2. Read-write (the default TPC-B-like script). Seven statements per transaction: three updates, a select and an insert, inside BEGIN and END.

Each run lasted 15 seconds. The methodology had three parts:

Advertisement

  • Direct: clients connect to Postgres at 8, 16, 32, 64, 200 and 400 connections, with max_connections set to 500.
  • Pooled: 200 clients connect to PgBouncer in transaction mode, with the pool size set to 4, 8, 16, 32, 64 and 100. We also ran 400 clients with a pool of 16.
  • Connect per transaction: pgbench -C opens a new connection for every transaction, which is how a function-per-request app behaves, with and without PgBouncer.

Everything ran on one machine over loopback, so network time is near zero. That favors the direct path, and we say what it means for a real network below.

Results: what happens when a new connection costs a handshake?

With a new connection per transaction, PgBouncer was 19x faster at 16 clients and 20x faster at 64 clients. This is the case a pooler was built for.

Bar chart of transactions per second with a new connection per transaction, direct to Postgres and through PgBouncer, at 16 and 64 clients.

Clients Direct to Postgres Through PgBouncer Latency, direct Latency, pooled
16 949 tps 17,950 tps 16.9 ms 0.89 ms
64 869 tps 17,262 tps 73.7 ms 3.7 ms

Without a pooler, the throughput barely moved from 16 to 64 clients, and latency grew by 4x. Postgres spent its time starting and stopping backend processes. With PgBouncer, the client opens a cheap connection to the pooler, which hands it an already-open server connection.

This is the pattern behind serverless functions and edge runtimes. Each invocation may open its own connection, and a burst of 500 invocations can hit the database as 500 new connections. The netdata guide to sizing a PgBouncer pool frames the same need as a pool that reuses connections across short transactions.

For example, a Next.js route on a serverless platform that runs one query per request, with no pooling, pays for a new connection on every request. Our connect-and-query transactions averaged 16.9 ms at 16 clients. At 100 requests per second, that is 1.7 seconds of database latency added for every second of traffic.

Results: what happens when clients stay connected?

With persistent connections, adding clients past the core count made Postgres slower, and PgBouncer capped the total at a lower level.

Bar chart of read-write transactions per second for direct Postgres connections at 8 to 400 clients and for PgBouncer at 200 and 400 clients.

Direct clients Select-only tps Read-write tps Read-write latency
8 151,419 21,144 0.38 ms
16 161,168 23,433 0.68 ms
32 160,706 18,404 1.74 ms
64 187,114 17,036 3.76 ms
200 145,340 12,631 15.8 ms
400 119,937 7,675 52.1 ms

Read-write throughput peaked at 16 clients, which is twice the 8 cores. By 400 clients it had fallen to 33% of the peak, and the average transaction took 76 times longer (our lab, 2026). Select-only peaked later, at 64 clients, because a read is shorter and waits less on the disk.

This is the argument for pooling. More connections than the CPU and disk can serve do not add work, they add queueing and context switching. The EnterpriseDB article makes the case for a pooler rather than a very high max_connections.

But the pooled numbers are not a clean win. With 200 clients connected persistently through PgBouncer:

Pool size Select-only tps Read-write tps Read-write latency
4 62,649 4,806 41.6 ms
8 65,381 5,894 33.9 ms
16 64,332 4,376 45.7 ms
32 54,990 4,626 43.2 ms
64 51,311 5,142 38.9 ms
100 58,968 5,185 38.6 ms

Direct connections with 200 clients managed 145,340 select-only and 12,631 read-write transactions per second (our lab, 2026). The pooled best was 65,381 and 5,894, which is 45% and 47% of the direct result. With 400 clients and a pool of 16, read-write latency was 75.4 ms pooled against 52.1 ms direct.

So on a fast local database, a pooler lowered both throughput and latency. It did not make Postgres faster. It made 200 clients possible at a cost.

Why did pool size barely matter?

Pool size barely changed the result because PgBouncer itself was the bottleneck, not the number of server connections behind it. The pooler is a single process that handles every client message.

We checked while the select-only run was in progress. The pgbouncer process used about 75% of one core, more than any single Postgres backend, which sat near 19% each. The throughput ceiling of 51,000 to 65,000 transactions per second looks like the speed of that one process.

The read-write workload explains the rest. Each read-write transaction sends seven statements, and every statement passes through the pooler. At about 5,000 transactions per second, that is about 35,000 statements per second, which is within a factor of two of the select-only ceiling. Chatty transactions hit the pooler's limit sooner than single-query ones.

That is a measured property of this setup, not a verdict on the software. A pooler on a networked database has different economics, because the database round trip is milliseconds, not microseconds, and the pooler's hop is small beside it. We did not test a remote database, TLS or a multi-process pooler setup, so treat our throughput ratios as a floor on how much a pooler can cost.

How big should the pool be, then? The netdata guide applies Little's Law, which says the number of busy connections equals the arrival rate times the time each one is held. Its example is 200 transactions per second at 100 ms each, which needs about 20 connections. Our direct curve gives a second signal. Read-write throughput peaked at 16 clients on 8 cores, so a pool of 2 to 3 times your core count is a sound starting point, and you then tune it against measured wait time.

The error that says you need a pooler

The error message is FATAL: sorry, too many clients already, and it appears when clients outnumber max_connections. We reproduced it with max_connections set to 100 and a pgbench run of 150 clients:

pgbench: error: connection to server on socket "/tmp/.s.PGSQL.54019" failed: FATAL:  sorry, too many clients already
pgbench: error: could not create connection for client 101

Client 101 failed because 100 slots were taken. Through PgBouncer, the same 150 clients ran at 75,777 transactions per second, and Postgres saw 16 server connections plus one admin session.

Raising max_connections is the tempting fix, and it has a price. Every slot can become a backend process with its own memory, and our own curve shows that more concurrent backends reduce throughput once the machine is busy. A pooler fixes the count without that cost. The related queueing math is the same idea as the limiter designs in Rate limiter algorithms compared: the 2x burst most miss, where a fixed budget protects a shared resource.

A working setup

This is a minimal pgbouncer.ini for transaction pooling, the one we used. Change the pool size to match your cores and your measured wait time.

[databases]
app = host=127.0.0.1 port=5432 dbname=app

[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 16
max_prepared_statements = 100

The reference lists default_pool_size as the maximum number of server connections per user and database pair, with a default of 20. It also documents max_prepared_statements, which makes PgBouncer track protocol-level prepared statements in transaction mode so the statement exists on whichever server connection the next transaction gets. Without it, ORMs that use prepared statements can fail in transaction mode.

The app side needs two changes. Point the connection string at port 6432, and keep the app's own pool small, because the pooler now does the sharing. In Node with pg:

import pg from "pg";

export const pool = new pg.Pool({
  host: "127.0.0.1",
  port: 6432,            // PgBouncer, not Postgres
  database: "app",
  max: 10,               // per app instance; instances x max may exceed the pool size
});

Transaction mode has limits you must design around. Session state does not survive between transactions, so SET commands, session-level advisory locks, LISTEN and temporary tables can land on the wrong server connection. Use SET LOCAL inside a transaction, transaction-level advisory locks, and a direct connection for LISTEN.

To check your own numbers before you deploy, run these two commands against your staging database, once direct and once through the pooler:

pgbench -h HOST -p 5432 -U app -c 64 -j 4 -T 15 -S app       # direct
pgbench -h HOST -p 6432 -U app -c 64 -j 4 -T 15 -S app       # pooled

Add -C to either to see the connect-per-request cost. If the pooled run is much slower and your app holds connections open, the pooler is not buying you anything yet.

When should you add PgBouncer?

Add a pooler when your clients outnumber your database's useful concurrency, or when connections are short-lived. Skip it when a few long-lived app servers already hold a small, steady set of connections.

  1. Serverless or per-request connections: add it. In our lab it gave 19x on connect-per-request traffic, and the gain is likely larger with TLS and network distance, which we did not test.
  2. Many app instances, each with its own pool: add it. Twenty instances with 20 connections each is 400 backends, and our direct curve shows 400 clients at 33% of peak read-write throughput.
  3. A few app servers with steady pools under 50 connections: skip it. Direct was faster in every persistent-connection run we measured.
  4. You hit too many clients already: add it before you raise max_connections.

Whatever you choose, watch two numbers: how long clients wait for a server connection, and the CPU use of the pooler process. If the pooler sits near one full core, it is your ceiling, and the answer is more poolers behind a load balancer rather than a bigger pool.

If you are tuning a database for keys and queries as well, we measured related costs in UUIDv7 or UUIDv4 keys in Postgres: 11M rows, 22x slower and Postgres OFFSET vs keyset: page 100,000 is 6,000x slower. If your own numbers differ from ours, contact the NiharDaily editor with your hardware and pgbouncer.ini, and we will compare them with the lab.

The bottom line: a pooler is a trade, not a turbo

PgBouncer turned 949 transactions per second into 17,950 when every request opened a connection, and it held 150 clients on 16 backends where Postgres alone refused client 101. It also halved throughput for 200 persistent clients on a local database, and pool size changed little because one process carried the traffic (our lab, 2026).

Add it for serverless, many instances or connection-limit errors. Leave it out for a few steady app servers. In both cases, measure with pgbench on your own hardware before you trust anyone's ratio, including ours.

Advertisement

FAQ

Does PgBouncer make Postgres faster?

Not for persistent connections on a fast local database. In our lab, 200 clients got 145,340 select-only transactions per second directly and 65,381 through PgBouncer. It is faster when each request opens a new connection, where we measured 19 times, because the pooler removes the connection setup cost.

What pool size should I use for PgBouncer?

Start at 2 to 3 times your database CPU cores, then tune against measured wait time. In our test, read-write throughput peaked at 16 clients on 8 cores. Little's Law gives a second check: 200 transactions per second at 100 ms each needs about 20 connections.

What does sorry, too many clients already mean?

It means more clients tried to connect than the Postgres max_connections setting allows. We reproduced it with max_connections at 100 and 150 clients, where client 101 failed. A pooler such as PgBouncer shares a small set of server connections, so the error goes away without raising the limit.

Can PgBouncer transaction mode break my app?

Yes, if the app relies on session state. SET, session advisory locks, LISTEN and temporary tables may land on a different server connection in transaction mode. Use SET LOCAL, transaction-level locks and a direct connection for LISTEN, and set max_prepared_statements if your driver uses prepared statements.

Is PgBouncer single-threaded?

In our run the pgbouncer process used about 75 percent of one core while serving select-only traffic, and throughput stayed between 51,000 and 65,000 transactions per second whatever the pool size. That points to one process as the ceiling. Run several instances behind a load balancer if you reach it.

Comments

Loading…

Sign in to join the conversation.

Related posts