PgBouncer transaction pooling, and what it breaks

What it is

PostgreSQL uses a process per connection. Every client connection forks a backend process with its own memory (roughly 5 to 10 MB of private memory before any query work), and they all contend on shared structures. A few hundred connections is comfortable; a few thousand degrades badly, and the degradation is not graceful.

PgBouncer sits between the application and PostgreSQL, holding a small pool of real server connections and multiplexing many client connections onto them. It has three pooling modes, and the entire topic is the difference between them:

ModeA server connection is heldMultiplexingBreaks
SessionFor the client's whole connectionAlmost noneNothing
TransactionFor one transactionHighSession state
StatementFor one statementHighestMulti-statement transactions

Transaction pooling is the one that matters and the only one that delivers a large ratio. A server connection is checked out when a transaction begins and returned when it commits or rolls back, so 2,000 idle-between-transactions clients can share 40 server connections.

What it is confused with: a connection pool in your application (HikariCP, pgx's pool, SQLAlchemy's) is not a substitute. An application pool bounds connections per instance. With 60 instances each holding a 20-connection pool, PostgreSQL sees 1,200 connections regardless of how well each pool is tuned. PgBouncer bounds the total across all instances, which is the number PostgreSQL cares about. You generally want both: a small application pool per instance, and PgBouncer bounding the fleet.

The problem it solves

The concrete cost of too many PostgreSQL connections, in three parts:

Memory. Each backend uses several megabytes of private memory before it does any work, and work_mem allocations are per sort node per backend on top. A thousand connections is gigabytes of overhead for processes that are mostly idle.

Contention. Backends contend on the lock manager, the buffer mapping table, and ProcArray, the structure listing every active backend. Taking a snapshot walks ProcArray, so the cost of starting a transaction grows with the number of connections, including idle ones. This is why throughput can fall as connections rise past the useful point, which is the coherence term of the Universal Scalability Law showing up in a specific system.

Connection establishment cost. A new PostgreSQL connection forks a process, runs authentication, and populates catalog caches: typically 20 to 50 ms. For a serverless or short-lived workload that is paid per request.

The measured shape: throughput on PostgreSQL typically peaks somewhere between 2x and 4x the core count in active connections and declines beyond it. A 16-core machine does its best work with roughly 30 to 60 active connections, not 500.

Mechanics

Configuration

[databases]
prod = host=10.0.1.20 port=5432 dbname=prod

[pgbouncer]
pool_mode = transaction
listen_port = 6432

max_client_conn = 5000          ; how many APP connections PgBouncer accepts
default_pool_size = 40          ; server connections PER (user, database) pair
reserve_pool_size = 10          ; emergency extras
reserve_pool_timeout = 3        ; seconds waiting before reserve opens
max_db_connections = 80         ; hard cap per database
server_idle_timeout = 600
query_wait_timeout = 20         ; fail fast rather than queue forever

; Transaction mode is incompatible with server-side prepared statements
; on PgBouncer < 1.21. From 1.21+, this makes them work:
max_prepared_statements = 200

The ratio is the point: max_client_conn = 5000 with default_pool_size = 40 means 5,000 application connections share 40 PostgreSQL backends. That works because in a typical OLTP application, a connection is inside a transaction for a small fraction of its life.

Sizing default_pool_size is arithmetic, not intuition, and it is Little's Law again:

Target throughput:      3,000 transactions/sec
Mean transaction time:  8 ms
Required concurrency  = 3000 x 0.008 = 24 server connections
Plus headroom for variance                 -> 40

Bigger is not better. A pool of 200 against a 16-core machine puts 200 active backends on 16 cores, which is where contention costs exceed parallelism gains. The pool size should be near the database's optimal concurrency, and PgBouncer's queue absorbs the rest.

What transaction pooling breaks

This is the substance of the topic. In transaction mode, consecutive statements from one client can land on different server connections, so anything PostgreSQL stores per session is unreliable.

1. Session-level SET

SET work_mem = '256MB';           -- applies to whatever server connection was used
SELECT ...;                       -- may run on a DIFFERENT connection: default work_mem

The fix is SET LOCAL inside an explicit transaction, which is scoped to the transaction and therefore to the checkout:

BEGIN;
SET LOCAL work_mem = '256MB';
SELECT ...;
COMMIT;

2. Session-level advisory locks

SELECT pg_advisory_lock(12345);      -- held by a server connection you no longer own
-- ... other clients' transactions now run on that connection, holding your lock
SELECT pg_advisory_unlock(12345);    -- probably a DIFFERENT connection: fails

This is the dangerous one, because it does not error, it leaks a lock on a shared connection. Use pg_advisory_xact_lock, which is released automatically at transaction end:

BEGIN;
SELECT pg_advisory_xact_lock(12345);   -- released on COMMIT/ROLLBACK, guaranteed
-- work
COMMIT;

3. LISTEN / NOTIFY

LISTEN registers interest on a session. In transaction mode the session is not yours after the transaction ends, so notifications go to whoever holds that connection. LISTEN/NOTIFY cannot work through transaction pooling at all. The options are a separate direct connection (bypassing PgBouncer) for the listener, or a different mechanism entirely (a queue).

4. WITH HOLD cursors and temporary tables

Both are session-scoped. A temp table created in one transaction is invisible to the next, and worse, it may still exist on a server connection some other client is now using, causing confusing name collisions.

5. Prepared statements

This is the one that bites hardest because it is invisible: most drivers use prepared statements automatically. A PREPARE on one connection is not visible from another, so the EXECUTE fails with prepared statement "S_1" does not exist.

PgBouncer 1.21 (released 2023) added max_prepared_statements, which tracks prepared statements per client and re-prepares them on whichever server connection is assigned. This changed the standard advice substantially: before 1.21 you disabled server-side prepared statements, and now you can keep them.

If on an older PgBouncer, disable them at the driver:

# JDBC
jdbc:postgresql://pgbouncer:6432/prod?prepareThreshold=0

# Go, pgx
pool_config.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol

# Python, asyncpg
await asyncpg.connect(..., statement_cache_size=0)

The cost of disabling them is real: parse and plan on every execution, typically 5 to 15 percent more CPU on the database and higher latency on complex queries. Upgrading PgBouncer to 1.21+ to get prepared statements back is usually worth more than any other tuning here.

The compatibility summary

FeatureSession modeTransaction mode
SET (session)WorksBroken: use SET LOCAL in a transaction
SET LOCALWorksWorks
Prepared statementsWorksWorks on 1.21+ with max_prepared_statements
pg_advisory_lockWorksBroken: use pg_advisory_xact_lock
LISTEN / NOTIFYWorksBroken: needs a direct connection
Temp tablesWorksBroken
WITH HOLD cursorsWorksBroken
Cursors within a transactionWorksWorks
Multi-statement transactionsWorksWorks

Monitoring

-- Connect to PgBouncer's admin database on the same port.
SHOW POOLS;
 database | user | cl_active | cl_waiting | sv_active | sv_idle | maxwait
----------+------+-----------+------------+-----------+---------+---------
 prod     | app  |        38 |        412 |        40 |       0 |      8.2

cl_waiting and maxwait are the metrics that matter. cl_waiting = 412 with maxwait = 8.2 means 412 client connections are queued and the oldest has waited 8.2 seconds. sv_idle = 0 confirms the pool is saturated.

The important diagnostic distinction: a saturated pool is usually a symptom, not the problem. Transactions are being held too long (a slow query, an application holding a transaction open across a network call, a missing index), and the pool queue is where it becomes visible. Raising default_pool_size in response typically makes it worse, by putting more concurrent load on a database that is already the constraint.

A worked example: 1,400 connections, and a transaction held across an HTTP call

An e-commerce backend. 70 application pods, HikariCP with a pool of 20 each, connecting directly to PostgreSQL on a 16-core, 128 GB instance.

Symptoms:

PostgreSQL connections:        1,398 of max_connections 1,500
p99 API latency:               2,900ms
database CPU:                  34%              <- not CPU-bound
p99 query time (pg_stat_statements): 4ms         <- queries are FAST
connection errors:             ~40/min "remaining connection slots are reserved"

The tell is CPU at 34 percent with 1,400 connections and 4 ms queries. The database was not working hard; it was managing connections. The team's instinct was to raise max_connections, which they had already done twice.

Step 1: PgBouncer in transaction mode.

pool_mode = transaction
max_client_conn = 4000
default_pool_size = 40           ; from 3000 tps x 8ms = 24, plus headroom

with HikariCP repointed at PgBouncer and shrunk (the app pool now only needs to bound per-pod concurrency, not the fleet):

maximumPoolSize: 20 -> 10        # 70 pods x 10 = 700 client conns, well under 4000

It broke immediately. The JDBC driver used server-side prepared statements by default, and PgBouncer was 1.18:

org.postgresql.util.PSQLException: ERROR: prepared statement "S_3" does not exist

Two options. They took the fast one first (prepareThreshold=0), measured a 12 percent CPU increase on the database from re-planning, then upgraded PgBouncer to 1.21 and turned prepared statements back on:

max_prepared_statements = 200

Step 2: the second breakage, which was silent. An admin endpoint used pg_advisory_lock to serialise a nightly job. In transaction mode the unlock ran on a different server connection and failed silently in a swallowed exception, so the lock stayed held on a pooled connection. Two nights later the job ran twice concurrently. Changed to pg_advisory_xact_lock inside an explicit transaction.

Step 3: the pool saturated anyway.

 database | cl_active | cl_waiting | sv_active | sv_idle | maxwait
----------+-----------+------------+-----------+---------+---------
 prod     |        40 |        380 |        40 |       0 |     11.4

Forty server connections all busy and 380 clients queued. The instinct was to raise default_pool_size, and instead they asked what was holding the connections, since pg_stat_statements said queries averaged 4 ms.

SELECT pid, state, now() - xact_start AS txn_age, query
  FROM pg_stat_activity
 WHERE state <> 'idle' AND xact_start IS NOT NULL
 ORDER BY xact_start LIMIT 10;
 pid  |        state        | txn_age  | query
------+---------------------+----------+------------------
 4412 | idle in transaction | 00:00:02 | SELECT ... orders
 4418 | idle in transaction | 00:00:02 | SELECT ... orders
 ...

idle in transaction, consistently around 2 seconds. The checkout endpoint did this:

@Transactional                                    // transaction OPENS here
public Order checkout(CheckoutRequest req) {
    Order order = orderRepository.save(...);      // 3ms
    PaymentResult p = paymentGateway.charge(...); // 1,900ms  <- HTTP call, IN the txn
    order.setStatus(p.isOk() ? PAID : FAILED);
    return orderRepository.save(order);           // 2ms
}                                                 // transaction commits here

A 1.9-second external HTTP call inside a database transaction. Five milliseconds of database work held a connection for nearly two seconds. At 40 connections that caps checkout throughput at about 21 per second regardless of how fast the database is, which is Little's Law giving the ceiling exactly.

Directly against PostgreSQL with 1,400 connections this had been hidden, because there were enough connections to absorb it. PgBouncer did not cause this problem; it made an existing one visible by bounding the resource it was wasting.

The fix:

public Order checkout(CheckoutRequest req) {
    Order order = txTemplate.execute(s -> orderRepository.save(...));   // txn 1: 3ms
    PaymentResult p = paymentGateway.charge(...);                        // NO txn open
    return txTemplate.execute(s -> {                                     // txn 2: 2ms
        order.setStatus(p.isOk() ? PAID : FAILED);
        return orderRepository.save(order);
    });
}

Two short transactions with the network call between them. Connection hold time went from 1,900 ms to about 5 ms.

Measured:

                              before      after PgBouncer   after txn fix
PostgreSQL connections        1,398       40                40
p99 API latency               2,900ms     3,400ms (worse!)  180ms
checkout throughput           ~20/s       ~21/s             ~2,400/s
database CPU                  34%         31%               58%
database memory (backends)    11.2 GB     0.4 GB
cl_waiting (PgBouncer)        n/a         380               0-3
connection errors             ~40/min     0                 0

PgBouncer alone made p99 latency worse, from 2,900 ms to 3,400 ms, because the queueing that had been spread across 1,400 connections was now concentrated in a visible queue. That is worth stating plainly: connection pooling does not create capacity, it allocates it, and if the underlying problem is that transactions are held too long, a pool surfaces it as a queue rather than fixing it.

The 120x checkout throughput improvement came from the transaction scope change, which was only diagnosable because the pool made the hold time visible. Database CPU went up, from 31 to 58 percent, which is the correct direction: the database was finally doing work rather than managing connections.

Production evidence

PgBouncer is the standard PostgreSQL pooler and transaction mode is what nearly everyone runs it in. Its documentation contains an explicit table of which features are incompatible with each pooling mode, which is the reference for the compatibility list above.

PgBouncer 1.21 added prepared-statement support in transaction mode (max_prepared_statements), tracking them per client and re-preparing on the assigned server connection. This was a long-requested feature and it removed the most common reason teams could not use transaction pooling.

Amazon RDS Proxy, Google Cloud SQL's built-in pooler, and Supabase's Supavisor all implement transaction pooling with the same constraints, and all document the same incompatibilities. That every managed provider built one is evidence that PostgreSQL's connection model requires external pooling at scale.

Odyssey (Yandex) is the main alternative, multi-threaded where PgBouncer is single-threaded per process, which matters above roughly 20,000 connections where PgBouncer itself becomes CPU-bound on one core. The standard PgBouncer answer to that is so_reuseport with multiple processes.

HikariCP's documentation on pool sizing makes the same argument this page makes: smaller pools outperform larger ones, and their recommended formula (connections = ((core_count * 2) + effective_spindle_count)) produces numbers far smaller than most teams' intuition. Their write-up cites the same throughput-peaks-then- declines curve.

The debate

Session or transaction mode? Transaction, unless something in the compatibility table forces otherwise. Session mode gives almost no multiplexing (a connection is held for the client's whole life), so it solves the connection-establishment cost and not the connection-count problem, which is the one that matters. My position: use transaction mode, fix the incompatibilities in the application, and run a small separate session-mode pool on a different port for the few things that genuinely need session state. That split is common and works well.

Is PgBouncer necessary if the application pools? Yes, at scale, because application pools bound connections per instance and PostgreSQL cares about the total. Sixty instances with a 20-connection pool is 1,200 connections whatever each pool does. PgBouncer is the only place that number can be bounded. The corollary is that when you add PgBouncer you should shrink the application pool, since its job changes from "limit database load" to "limit per-instance concurrency."

How big should the pool be? Little's Law: target_tps x mean_transaction_seconds, plus headroom, and then check it against the database's capacity (roughly 2 to 4 times core count in active connections). The mistake is treating a saturated pool as under-sized. A queue in PgBouncer usually means transactions are held too long, and enlarging the pool moves the queue into the database where it is worse. Check idle in transaction durations before raising default_pool_size.

Is PgBouncer a single point of failure? It is on the path for every query, so yes. Run at least two behind a virtual IP or as a sidecar per application pod. The sidecar pattern is worth considering carefully: it removes the network hop and the shared failure domain, and it gives up the fleet-wide bound, which was the reason for introducing it. My default is a small centralised HA pair or a per-node deployment, not per-pod.

Should you use statement mode? Almost never. It returns the connection after every statement, so multi-statement transactions are impossible, which means no BEGIN at all. The narrow use is a workload that is entirely single-statement autocommit reads, and transaction mode handles that case just as well with fewer surprises.

Follow-up Q&A

"Why does PostgreSQL need external pooling at all?"

Process per connection. Each backend is an OS process with several megabytes of private memory, and they contend on shared structures including ProcArray, which is walked when taking a snapshot, so the cost of starting a transaction grows with the number of connections including idle ones. Throughput peaks around 2 to 4 times core count in active connections and declines beyond it. That decline is the coherence term of the Universal Scalability Law in a specific system, and no amount of max_connections fixes it.

"What breaks in transaction mode?"

Anything session-scoped, because consecutive statements can land on different server connections. Session-level SET (use SET LOCAL inside a transaction), session advisory locks (use pg_advisory_xact_lock), LISTEN/NOTIFY (needs a direct connection), temp tables, and WITH HOLD cursors. Prepared statements used to be on that list and are supported from PgBouncer 1.21 via max_prepared_statements. The advisory lock one is the most dangerous because it fails silently and leaks a lock onto a shared connection.

"cl_waiting is 400 and maxwait is 10 seconds. Do you raise the pool size?"

Not first. A saturated pool usually means transactions are being held too long, so I would check pg_stat_activity for idle in transaction and for long transaction ages. The classic cause is an external call inside a transaction: a payment gateway, an HTTP request, an S3 upload, holding a connection for seconds while doing 5 ms of database work. Raising the pool moves the queue into the database, where 200 concurrent backends on 16 cores is worse than a queue in PgBouncer. Fix the hold time, then re-check.

"How do you size the pool?"

target_tps x mean_transaction_seconds gives required concurrency: 3,000 tps at 8 ms is 24, so 40 with headroom. Then sanity-check against the database: active connections should be roughly 2 to 4 times core count, so a 16-core machine wants something like 30 to 60, and a pool of 200 would be past the point where contention costs exceed parallelism.

"Your application already pools. Why add another layer?"

Because the application pool bounds connections per instance and PostgreSQL cares about the fleet total. Seventy pods with a pool of 20 is 1,400 connections no matter how each pool is configured, and the only place to bound that is in front of the database. When you add PgBouncer you should also shrink the application pool, because its purpose changes from limiting database load to limiting per-instance concurrency.

"You add PgBouncer and latency gets worse. What happened?"

You bounded a resource that was previously over-allocated, so contention that was spread thin across 1,400 connections is now a visible queue. That is diagnostic rather than a regression: pooling allocates capacity, it does not create it. The queue is telling you that transaction hold time times throughput exceeds the pool, and the fix is almost always to shorten hold time rather than to enlarge the pool.

Common misconceptions

"PgBouncer makes the database faster." It reduces connection overhead and bounds concurrency to a range where the database performs well. It adds a network hop and it creates a queue that makes existing problems visible. Throughput improves because the database stops managing connections, not because queries get faster.

"An application connection pool is enough." It bounds per instance. The fleet total is what PostgreSQL sees, and only a shared pooler can bound that.

"A bigger pool handles more load." Beyond the database's optimal concurrency, more active connections reduce throughput. The pool should be near that optimum, with PgBouncer's queue absorbing excess.

"Transaction mode is fine, we do not use anything exotic." Prepared statements are used automatically by most drivers, and they were incompatible before PgBouncer 1.21. Advisory locks and SET appear in ordinary code. "We do not use anything exotic" is usually untrue.

"SET LOCAL and SET are interchangeable." SET is session-scoped and unreliable under transaction pooling; SET LOCAL is transaction-scoped and safe. Using SET outside an explicit transaction under transaction pooling applies the setting to a connection you are about to hand back to someone else.

Interview delivery note

Say this verbatim: "Transaction pooling breaks anything session-scoped, because consecutive statements can land on different server connections: session SET, session advisory locks, LISTEN/NOTIFY, temp tables, and prepared statements before PgBouncer 1.21. The advisory lock is the dangerous one, because it does not error, it leaks a lock onto a connection someone else is now using." Naming a failure that is silent rather than loud is what shows you have operated it.

The senior-versus-staff separator is treating a saturated pool as a symptom. A senior engineer sees cl_waiting climbing and raises default_pool_size. A staff engineer checks idle in transaction first, finds a payment call inside a @Transactional method holding a connection for 1.9 seconds to do 5 ms of database work, and recognises that the pool did not cause the problem, it made an existing one visible by bounding the resource being wasted.

The second signal is expecting latency to get worse when you introduce pooling. Saying "p99 may regress initially because contention that was spread across 1,400 connections becomes a visible queue, and that queue is the diagnosis" shows you understand that pooling allocates capacity rather than creating it.

Further reading

  • PgBouncer documentation, particularly the feature-compatibility matrix per pooling mode and the SHOW POOLS reference.
  • PgBouncer 1.21 release notes on max_prepared_statements, which changed the standard advice on prepared statements in transaction mode.
  • HikariCP's "About Pool Sizing" wiki page, for the argument that small pools outperform large ones, with the throughput curve.
  • PostgreSQL documentation on max_connections and the connection-per-process model, for why an external pooler is needed at all.