Every Postgres connection is a full backend process with its own memory. Open a few hundred and the database spends more effort juggling connections than running queries. PgBouncer fixes that by putting a lightweight pooler in front of Postgres. But a lot of the advice online is from 2023 or earlier, and some of it is now wrong. This guide covers the settings that matter as of PgBouncer 1.26 (September 2026).
Which PgBouncer version are you on?
Check first, because the answer changes the advice:
| Version | Released | What changed for you |
|---|---|---|
| 1.21 | Oct 2023 | Protocol-level prepared statements in transaction mode (max_prepared_statements) |
| 1.24 | Jan 2025 | max_prepared_statements defaults to 200, so prepared statements work out of the box |
| 1.25 | Nov 2025 | LDAP auth, transaction_timeout, a notice to clients waiting in the queue |
| 1.26 | Sep 23, 2026 | Three security fixes, per-user/database query_wait_timeout, online restart removed |
Use transaction pooling for web apps
PgBouncer has three modes:
- Session: a client keeps one server connection until it disconnects. Safe, but barely reduces connections. This is the default.
- Transaction: a client holds a server connection only for the length of a transaction. This is where the big win is.
- Statement: released after every statement. Multi-statement transactions are not allowed, so it is rarely useful.
For APIs, web apps and workers, use pool_mode = transaction. A few dozen server connections can then serve thousands of clients.
What breaks in transaction mode
Anything that relies on state living in a session breaks, because your next transaction may run on a different server connection:
SET/RESETof session settings (useSET LOCALinside a transaction, or set defaults on the role)LISTEN(NOTIFY is fine)- Session-level advisory locks (use
pg_advisory_xact_lockinstead) - SQL-level
PREPARE/EXECUTE/DEALLOCATE WITH HOLDcursors, and temp tables that outlive a transaction
Prepared statements: the outdated myth
Many guides still say "prepared statements do not work with PgBouncer in transaction mode." That has not been true since 1.21. PgBouncer now tracks protocol-level prepared statements, the kind most drivers use (for example JDBC, pgx, asyncpg, node-postgres named statements), and re-prepares them on whichever server connection a client lands on.
; pgbouncer.ini (1.24+ default shown explicitly)
max_prepared_statements = 200Only SQL-level PREPARE commands remain unsupported. If your ORM disabled prepared statements years ago to work around PgBouncer, you can usually turn them back on and save the parse and plan cost of every query.
Size pools from the database backwards
The most common mistake is too many server connections, not too few. A database with 8 CPU cores cannot run 400 queries at once; it can only switch between them. HikariCP's well-known sizing guide starts from roughly (cores × 2) + disk spindles for the database, and its example of shrinking a pool from 2,048 to 96 connections made response times dramatically better, not worse.
A worked example for one Postgres primary with 8 vCPUs:
| Layer | Setting | Value | Why |
|---|---|---|---|
| Postgres | max_connections | 100 | Headroom for admin, replication and migrations |
| PgBouncer | default_pool_size | 20 | About 2 to 3 per core of real concurrent work |
| PgBouncer | reserve_pool_size | 5 | Short bursts without raising the base pool |
| PgBouncer | max_db_connections | 30 | Hard cap across all users of this database |
| PgBouncer | max_client_conn | 2000 | Clients are cheap to hold in PgBouncer |
| App | pool size per instance | 10 to 20 | App pools now connect to PgBouncer, not Postgres |
Then measure. If SHOW POOLS shows clients waiting (cl_waiting) while database CPU is low, raise the pool slightly. If database CPU is saturated, a bigger pool will make latency worse. Fix the queries burning CPU instead.
Set timeouts so one bad client can't starve the rest
query_wait_timeout = 30 ; give up waiting for a server connection (default 120s)
idle_transaction_timeout = 60 ; kill clients sitting idle inside an open transaction
server_idle_timeout = 600
server_lifetime = 3600idle_transaction_timeout matters most. A client that opens a transaction and then waits on an HTTP call holds a server connection hostage, which is one of the classic causes of APIs collapsing under load. Pair it with statement_timeout on the Postgres role.
Run it highly available
A single PgBouncer is a single point of failure.
- Run at least two instances behind a TCP load balancer or a service address, or run PgBouncer as a sidecar next to each app instance.
- With
so_reuseport = 1, several PgBouncer processes can share one port on a host and use more than one CPU core. - Keep
max_db_connectionsper instance low enough that all instances together stay undermax_connections. - Monitor
SHOW POOLSandSHOW STATS, particularlycl_waitingand the average wait time.
Routing sessions that need state
Some clients genuinely need session mode: schema migrations, LISTEN consumers, or jobs using session advisory locks. Do not switch everything back to session mode for them. Define a second database entry pointing to the same Postgres database with its own mode, or have those clients connect to Postgres directly:
[databases]
app = host=10.0.0.5 dbname=app pool_mode=transaction
app_session = host=10.0.0.5 dbname=app pool_mode=session pool_size=5PgBouncer vs Pgpool-II vs RDS Proxy
| PgBouncer | Pgpool-II | RDS Proxy | |
|---|---|---|---|
| Main job | Connection pooling | Pooling plus routing, load balancing, failover | Managed pooling on AWS |
| Complexity | Low | High | Low (managed) |
| Prepared statements in pooled mode | Yes (protocol-level, 1.21+) | Varies by configuration | Can pin sessions |
| Cost | Free, you run it | Free, you run it | Per vCPU-hour of the database |
| Pick it when | Almost always | You need replica routing in the pooler | You want managed and pinning stays low |
The RDS Proxy caveat deserves detail. For Postgres, RDS Proxy pins a client to a single database connection for the rest of its session when it sees operations such as SET, PREPARE/EXECUTE, temporary tables, LISTEN, session advisory locks, nextval/setval, or statements larger than 16 KB. A pinned session is no longer pooled. If DatabaseConnectionsCurrentlySessionPinned is high, move SET statements into the proxy's initialization query, or consider PgBouncer.
Checklist
- Upgrade to PgBouncer 1.26.0.
pool_mode = transactionfor app traffic; a separate session-mode entry for the few clients that need it.- Re-enable driver prepared statements if you had disabled them for PgBouncer.
- Size
default_pool_sizefrom database cores, not from app instance count. - Set
query_wait_timeout,idle_transaction_timeoutand a Postgresstatement_timeout. - Run two or more instances and monitor
cl_waiting.