PGBouncer and the Real Limits of Postgres Connection Pooling
Postgres was never built to hold tens of thousands of open connections gracefully. Each one reserves real memory on the server, and past a few hundred concurrent connections, performance degrades well before you hit the hard connection ceiling.
PGBouncer sits between your application and Postgres and multiplexes many client connections onto a smaller pool of real ones. Getting that multiplexing wrong is one of the more common production incidents in apps that scale past a single app server.
Which PGBouncer pooling mode breaks prepared statements?
Session pooling assigns a Postgres connection to a client for the whole session, which is the safest mode and the one that changes the least about how your app behaves, at the cost of pooling almost nothing at scale. Transaction pooling releases the connection back to the pool as soon as a transaction commits, which is where most of the real capacity gain comes from, but it breaks any feature that depends on session state persisting across transactions: prepared statements, session-level advisory locks, and settings that aren't scoped to a transaction.
Statement pooling, the most aggressive mode, releases the connection after every single statement and is rarely worth the compatibility cost outside very specific batch workloads.
How big should your PGBouncer pool be?
The instinct is to size the pool close to your application's expected concurrency. The right number is usually much smaller than that, because Postgres itself performs best with a connection count in the low hundreds, and a well-tuned pool of real connections behind PGBouncer can serve a large multiple of that in concurrent app requests if your queries are fast.
If you're raising the pool size to fix a slowdown, check your query latency first. A pool that's too small surfaces as connection wait time in PGBouncer's own stats, not as a Postgres error, which is why it's easy to misdiagnose as a database problem.
Common failure modes and how to spot them
- Prepared statement errors after switching to transaction pooling, which show up as a driver-level error mentioning an unknown prepared statement name; disable the driver's prepared statement cache or switch that connection to session pooling.
- Connection pile-up during a deploy, where old and new app instances briefly hold two full pools at once; stagger your rollout or add a grace period before old instances release connections.
- A pool sized for steady-state traffic that falls over during a spike, which PGBouncer's pool status command will show as a growing wait queue well before Postgres itself reports any error.
- Idle-in-transaction connections holding a slot indefinitely because application code opened a transaction and never closed it, which a statement timeout on the Postgres side will catch.
A worked example: sizing a pool for a Monday-morning traffic spike
Say your app runs fifteen backend instances, each configured for fifty concurrent database connections, for a theoretical peak of several hundred direct connections, well past what a single Postgres instance handles comfortably. Route all fifteen instances through PGBouncer in transaction pooling mode with a much smaller real pool to Postgres.
Under normal load, that small real pool comfortably serves far more app-side connections because each one is only held for the duration of a transaction, not the duration of a request. The real test is whether your queries are fast enough that a transaction holds its connection for milliseconds, not seconds; if queries are slow, pooling can't fix the underlying capacity problem.
PGBouncer itself needs monitoring, not just Postgres
Teams that instrument Postgres carefully often leave PGBouncer as a black box in between, which is exactly backwards for diagnosing a connection problem, since PGBouncer's own stats are usually where the early warning shows up first. Its pool status output reports active, waiting, and idle connections per pool, and a client wait time that's climbing is the earliest signal that demand is outrunning your real connection count, well before Postgres itself shows any strain.
Export those stats into the same dashboard you already use for Postgres metrics, so an on-call engineer investigating a slowdown checks one place instead of two, and doesn't rule out pooling as the cause just because Postgres's own metrics still look calm.
Multiple pools instead of one pool for everything
A single PGBouncer pool shared by every workload means a slow analytical query and a fast transactional lookup are competing for the same limited real connections. Splitting into separate pools, one for latency-sensitive transactional traffic and another for longer-running reporting or batch queries, keeps a slow report from starving the checkout flow of connections it needs immediately.
This costs a bit more configuration up front but pays for itself the first time a badly written report query would otherwise have taken down an unrelated feature by exhausting the shared pool.
What Good Looks Like
A good connection pooling setup keeps your real database connection count low and stable while application-side concurrency scales, and a wait-queue spike shows up in the pooler's own stats before it becomes a Postgres-level outage.
Building The Capability (5-Stage Skill Ladder)
How to Get Started
Frequently Asked Questions
Should we use session pooling or transaction pooling?
Transaction pooling for almost everything, since it's where the real capacity gain comes from. Fall back to session pooling only for specific connections that need session-level state, like advisory locks or prepared statements your driver can't disable caching for, and keep those on a separate pool from the rest of your traffic.
How do we know if our connection pool is too small?
Check PGBouncer's pool status output for a growing wait queue; that shows up before Postgres itself reports any error. If wait time is climbing but query latency is normal, the pool is undersized. If query latency is also climbing, the real problem is slow queries, not pool size.
Why did switching to transaction pooling break our app?
Transaction pooling releases the connection back to the pool as soon as a transaction commits, which breaks anything depending on session state surviving across transactions, most commonly prepared statement caching in your database driver. Disabling that cache, or moving those specific connections to session pooling, usually resolves 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
Continuous Device Verification for a Zero-Trust API
How continuous device and identity verification actually works in a zero-trust architecture, and where to draw the line for a small engineering team.
PgBouncer in Production: A Connection Pooling Checklist
Why Postgres runs out of connections before it runs out of CPU, and a rollout checklist for putting PgBouncer in front of it safely.
The Connection Pool Setting That Takes Down Production at 2am
Why connection pools exhaust under load, how PgBouncer's pool modes actually differ, and the four settings worth checking before your next incident.
Rolling Out Zero Trust in Production Without a Broad Outage
A checklist for rolling out stricter API authentication and authorization in production, and the pitfalls that turn a rollout into an incident.
What Actually Happens When Your Database Runs Out of Connections
A step-by-step walkthrough of how connection exhaustion happens, why adding more app servers makes it worse, and how a pooler like PgBouncer fixes it.
The Connection Pool Checklist Most Teams Skip Until an Outage
A pre-flight checklist for database connection pooling that catches the pool-exhaustion mistakes most teams only discover during a production outage.