metadlabs
PerformanceGuide

PgBouncer Best Practices for 2026: Pooling, Sizing and Pitfalls

PgBouncer best practices for 2026: transaction mode with prepared statements, pool sizing math, safe timeouts, and when to pick Pgpool-II or RDS Proxy instead.

By Metad Labs team · Sep 25, 2026 · 7 min read

Key takeaways

  • Use transaction pooling for web apps. Prepared statements work in transaction mode since PgBouncer 1.21 and are on by default since 1.24, so a lot of old advice is outdated.
  • Size from the database backwards: a small server-side pool per CPU core, then let PgBouncer absorb thousands of client connections.
  • Know what transaction mode breaks: SET, LISTEN, session advisory locks, SQL-level PREPARE and WITH HOLD cursors.
  • Upgrade to 1.26.0 (released September 23, 2026), which fixes three security issues, including an unauthenticated crash.

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:

VersionReleasedWhat changed for you
1.21Oct 2023Protocol-level prepared statements in transaction mode (max_prepared_statements)
1.24Jan 2025max_prepared_statements defaults to 200, so prepared statements work out of the box
1.25Nov 2025LDAP auth, transaction_timeout, a notice to clients waiting in the queue
1.26Sep 23, 2026Three 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 / RESET of session settings (use SET LOCAL inside a transaction, or set defaults on the role)
  • LISTEN (NOTIFY is fine)
  • Session-level advisory locks (use pg_advisory_xact_lock instead)
  • SQL-level PREPARE / EXECUTE / DEALLOCATE
  • WITH HOLD cursors, 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.

ini
; pgbouncer.ini (1.24+ default shown explicitly)
max_prepared_statements = 200

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

LayerSettingValueWhy
Postgresmax_connections100Headroom for admin, replication and migrations
PgBouncerdefault_pool_size20About 2 to 3 per core of real concurrent work
PgBouncerreserve_pool_size5Short bursts without raising the base pool
PgBouncermax_db_connections30Hard cap across all users of this database
PgBouncermax_client_conn2000Clients are cheap to hold in PgBouncer
Apppool size per instance10 to 20App 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

ini
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 = 3600

idle_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_connections per instance low enough that all instances together stay under max_connections.
  • Monitor SHOW POOLS and SHOW STATS, particularly cl_waiting and 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:

ini
[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=5

PgBouncer vs Pgpool-II vs RDS Proxy

PgBouncerPgpool-IIRDS Proxy
Main jobConnection poolingPooling plus routing, load balancing, failoverManaged pooling on AWS
ComplexityLowHighLow (managed)
Prepared statements in pooled modeYes (protocol-level, 1.21+)Varies by configurationCan pin sessions
CostFree, you run itFree, you run itPer vCPU-hour of the database
Pick it whenAlmost alwaysYou need replica routing in the poolerYou 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

  1. Upgrade to PgBouncer 1.26.0.
  2. pool_mode = transaction for app traffic; a separate session-mode entry for the few clients that need it.
  3. Re-enable driver prepared statements if you had disabled them for PgBouncer.
  4. Size default_pool_size from database cores, not from app instance count.
  5. Set query_wait_timeout, idle_transaction_timeout and a Postgres statement_timeout.
  6. Run two or more instances and monitor cl_waiting.

Frequently asked questions

Does PgBouncer support prepared statements in transaction mode?

Yes, since version 1.21, for protocol-level prepared statements (the Parse/Bind messages most drivers use). It is controlled by max_prepared_statements, which defaults to 200 since version 1.24. SQL-level PREPARE and EXECUTE commands are still not supported in transaction mode.

Should I use session or transaction pooling?

Transaction pooling for typical web and API workloads, because a server connection is only held for the duration of a transaction, so a few dozen database connections can serve thousands of clients. Use session pooling only for clients that need session state, such as migrations, LISTEN, or long-lived session advisory locks, ideally on a separate PgBouncer database entry.

Do I still need PgBouncer if my app already has a connection pool?

Usually yes once you run more than a few app instances or serverless functions. Each instance's pool opens its own connections, so 20 instances with 20 connections each is 400 Postgres backends. PgBouncer multiplexes all of them onto a small, fixed number of server connections.

PgBouncer vs Pgpool-II: which should I use?

For connection pooling, PgBouncer. It is lightweight, single-purpose and well understood. Pgpool-II adds query routing, load balancing across replicas and failover handling, but with far more complexity. Pick it only if you specifically need those features in the pooling layer.

Is RDS Proxy better than PgBouncer on AWS?

RDS Proxy is managed and integrates with IAM and Secrets Manager, and it is billed per vCPU-hour of the database. But for Postgres it pins a client to one connection for the rest of the session on many common operations, including SET, advisory locks and nextval, which can quietly defeat pooling. Watch the DatabaseConnectionsCurrentlySessionPinned metric, or run PgBouncer yourself if pinning is frequent.

Still slow after trying this?

We profile your app, API or database, find the real bottleneck, and fix it. Most audits pay for themselves in cloud savings alone.

Book a free 30-min callor run our free Postgres health check →

Newsletter

Get the next post in your inbox

Practical write-ups on fast systems, AI and automation from the engineers doing the work. About every two weeks.

No pitch. Unsubscribe from any email.

Keep reading