Distributed Systems & Enterprise ResiliencePlaybook3 min readUpdated September 2026

The PgBouncer Checklist Most Teams Skip Before Production

PgBouncer looks like a five-minute install: point your app at it instead of the database, pick a pool size, done. Most of the incidents it causes come later, when a pool mode choice made on day one quietly breaks a feature that depends on session state, or a pool sized without reference to the database's own connection limit starves it under load.

This is the checklist worth going through before it's in front of production traffic, not after.

Which PgBouncer pool mode should you pick?

Session pooling keeps a connection assigned to one client for the life of its session, which preserves everything (prepared statements, session-level SET commands, advisory locks) but gives you the least connection reuse. Transaction pooling, the common default, returns the connection to the pool as soon as a transaction ends, which is where most of the multiplexing benefit comes from, but it breaks anything that depends on session state persisting across transactions: prepared statements issued outside a transaction, SET commands meant to apply to later queries, and session-level advisory locks all stop working the way you'd expect. Statement pooling is the tightest of the three and breaks even more. Know which one your ORM and application code actually assume before you pick one for you.

A common surprise here is an ORM that silently issues a session-level SET (for a search path, a timezone, a role) on connection open and assumes it stays in effect. Under transaction pooling that assumption is wrong the moment the connection gets handed to a different client between transactions, and the failure shows up as intermittent, hard-to-reproduce data in the wrong timezone or queries hitting the wrong schema, not as a clean error.

How do you size the PgBouncer pool?

PgBouncer's pool size should be set with Postgres's own max_connections in mind, and with the number of application instances that will share the pooler. A pool sized larger than what the primary can actually serve doesn't add capacity, it just lets more connections queue up waiting for the same limited set of backend connections, which shows up as latency rather than an outright rejection. If several app instances each run their own PgBouncer, the sum of their pool sizes, not any single instance's setting, is what has to fit under the database's real ceiling.

The timeouts that actually matter

Four settings decide what happens when something goes wrong: server_idle_timeout (how long an unused backend connection stays open), query timeout (how long a query can run before it's cut off), client idle timeout (how long a client can hold a connection without using it), and what happens to a transaction-pooled connection when a query inside it hangs, which is that the whole pooled connection is effectively stuck until the query resolves or times out, blocking every other client waiting for that slot. Set these deliberately; the defaults are conservative in ways that can either let a stuck query tie up a slot for far longer than you'd want, or close idle connections more aggressively than a bursty workload can tolerate.

Failover changes the config you need

When the primary fails over, every connection PgBouncer was holding to it becomes invalid at once, and the reconnect behavior your pooler falls back to determines whether that's a brief blip or a pile of failed requests. Pooling in front of a read replica needs its own configuration, separate from the primary's pool: replicas can go stale or get promoted, and a pooler that doesn't distinguish between the two roles can end up routing writes at a replica or reads at a primary that's mid-failover.

The mistake: double-pooling against your own ORM

Most ORMs ship with their own connection pool built in, and it's easy to leave that pool active while also routing through PgBouncer, on the theory that more pooling can't hurt. It can: the app-level pool and PgBouncer's pool end up fighting over how many connections are actually open, and the effective capacity the database sees is neither pool's configured size but some smaller number determined by how the two interact. Pick one layer to own pooling, size the other to pass connections through without holding its own reserve, and confirm with a load test that the number of backend connections under load matches what you actually configured.

Before PgBouncer sees production traffic, confirm the following:

  • Your application doesn't depend on session state such as prepared statements, SET commands or advisory locks, if you choose transaction pooling.
  • Pool size is derived from Postgres's max_connections, minus headroom for admin and replication, and divided across the PgBouncer instances sharing it.
  • You have set the server idle, query and client idle timeouts deliberately, and decided what happens to a transaction that stalls.
  • Failover behavior is tested, including how the pooler reconnects when the primary changes.
  • The ORM's built-in pool is disabled or sized so it doesn't compete with PgBouncer's own pool.
Executive Capability Standard

What Good Looks Like

A production-ready PgBouncer setup has a pool mode chosen against what the application actually needs from session state, a pool size that respects the database's real connection ceiling, and failover behavior that's been tested, not assumed.

Building The Capability (5-Stage Skill Ladder)

1. Learn:Read through your ORM's and application code's assumptions about session state (prepared statements, SET commands, advisory locks) before picking a pool mode.
2. Do Manually:Run a load test against a staging PgBouncer instance and watch actual backend connection counts against your configured pool size to confirm they match.
3. Delegate:Have a database-focused engineer own the pooler configuration and review it whenever max_connections or instance count changes.
4. Automate:Add alerting on pool saturation and query timeout rates so a misconfigured pool shows up before it becomes a latency incident.
5. Buy:Bring in database or infrastructure advisory for a one-time review if you're scaling past what your current pooling setup has ever been tested at.

How to Get Started

Frequently Asked Questions

Should we use transaction pooling by default?

It's the right default for most workloads because it gives the best connection reuse, but only after confirming your application doesn't depend on session state (prepared statements, SET commands, advisory locks) surviving across transactions. If it does, either switch those code paths to session pooling or rework them to not need session state.

How do we size PgBouncer's pool correctly?

Start from Postgres's max_connections, subtract headroom for admin and replication connections, then divide what's left across however many PgBouncer instances will share it. Size for the database's real ceiling, not for how many connections your application would like to have available.

Why did our advisory locks stop working after adding PgBouncer?

Advisory locks are session-scoped, and transaction pooling doesn't guarantee the same backend connection across calls, so a lock taken in one transaction can be gone by the next. Either move locking logic to run inside a single transaction, or use session pooling for the code paths that need it.

About the numbers

This guide doesn't quote a sourced benchmark. Figures in it are estimates or general guidance, so check them against your own numbers.

Related Guides