PgBouncer vs RDS Proxy: Picking a Postgres Pooler in 2026
Postgres runs out of connections long before it runs out of CPU. The classic shape: an application fleet scales from 12 pods to 60 for a morning peak, each pod holding a 10-connection pool, and an instance comfortable at 120 backends is suddenly carrying 600. No query got worse; everything got slower. Every backend is its own OS process with its own memory and scheduler slice, and once the run queue fills, context switching costs more than the work.
Two mature fixes exist on AWS: PgBouncer, the single-binary open-source pooler you run yourself, and Amazon RDS Proxy, the managed pooler that fronts RDS and Aurora. AWS published a detailed comparison in October (AWS Database Blog). Here is the performance-engineer version: what changes in your latency and memory profile, how to size the pool, and how to decide.
Why connection count hurts before CPU does
Before anyone talks about poolers, prove the problem exists:
SELECT state,
count(*) AS backends,
count(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_txn
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY state
ORDER BY backends DESC;
If active is small and idle five to fifty times larger, you have a connection-management problem, not a tuning problem. Multiply idle backends by the per-backend overhead — a few megabytes of baseline memory plus whatever work_mem and temp files touch — and that is RAM you keep paying for on every instance upgrade.
The Houston version is bursty: an energy trading desk quiet at 15 connections off-peak and at 500 during the 08:00 CT spike. Sizing an instance for the peak means paying for hardware that idles twenty hours a day.
The pooling mode decides everything else
PgBouncer supports three modes (PgBouncer config docs):
- session — a server connection is held for the whole client session. Safe for legacy apps, almost no multiplexing.
- transaction — returned to the pool when the transaction ends. This is the mode that turns 500 clients into 20 backends.
- statement — returned after each statement; multi-statement transactions are impossible. Mostly for PL/Proxy-style sharding.
RDS Proxy has one behaviour, equivalent to transaction mode, and it falls back to something like session mode when it detects session state it cannot multiplex. That fallback is pinning, the most important operational detail on the managed side.
If the application genuinely needs session semantics — advisory locks held across statements, LISTEN/NOTIFY, session temp tables, cursors that outlive a transaction, bare SET the app assumes persists — transaction pooling breaks it. Fix the application or skip the pooler.
PgBouncer: low latency, you own the pager
PgBouncer is an event-driven single binary. Run it as a sidecar and the extra hop is a loopback socket, so added latency per query is effectively noise:
[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 2000 ; clients allowed to connect
default_pool_size = 20 ; server connections per user/db pair
reserve_pool_size = 5 ; burst capacity
query_wait_timeout = 10 ; fail fast instead of queueing forever
server_idle_timeout = 600 ; reclaim idle backends
max_prepared_statements = 200
Two settings matter most. query_wait_timeout defaults to 0, so clients queue forever: the failure mode becomes an application-wide hang instead of a fast error. Set it. Second, prepared statements — PgBouncer 1.21 (October 2023) added protocol-level support in transaction mode, on by default since 1.24, but only for the extended query protocol. SQL-level PREPARE, EXECUTE, and DEALLOCATE are forwarded untracked and misbehave if a client expects them to survive across transactions.
The costs are honest. PgBouncer is single-threaded, so very high throughput makes it CPU-bound, and multiple instances via so_reuseport add deployment complexity. It has no native AWS IAM authentication — auth_file, auth_query, HBA, PAM, LDAP since 1.25 — so credential rotation is yours to build. In exchange, SHOW POOLS, SHOW STATS, and SHOW SERVERS show exactly how long queries waited for a backend.
RDS Proxy: pinning is the number to watch
RDS Proxy is Multi-AZ and fully managed, the right default when you run Lambda or Fargate and cannot attach a sidecar, or when you want IAM auth and Secrets Manager rotation without writing that code. It also routes around failover without waiting on DNS — up to 66% faster failover by AWS’s measurement (RDS Proxy docs).
The trap is pinning. Any session state the proxy cannot multiplex — a SET, a temp table, an untracked prepared statement, a cursor — pins that client to one database connection for the rest of the session, so pooling stops working exactly when traffic is highest. Watch DatabaseConnectionsCurrentlySessionPinned in CloudWatch; the proxy logs name the operation that caused the pin. Common fixes: move SET statements into the proxy initialization query, and make sure drivers use the extended query protocol.
The knobs are coarse: MaxConnectionsPercent (a percentage of the instance’s max_connections), MaxIdleConnectionsPercent, IdleClientTimeout (default 1,800 seconds), ConnectionBorrowTimeout (default 120 seconds, after which requests fail rather than wait). Pricing runs per vCPU-hour of the underlying instance — $0.015 per vCPU-hour in US East with a 2-vCPU minimum — so on a small database with a few application servers, the managed option can cost more than the problem it solves. Constraints: a proxy attaches to the writer, not to read replicas; streaming replication mode is unsupported; lastval() is unreliable behind it (use INSERT ... RETURNING); the default postgres database must exist.
Sizing the pool: the number people get wrong
The starting point is not “one connection per core” and not “as many as the app asks for”. The community heuristic (PostgreSQL wiki) is:
connections = (CPU cores * 2) + effective_spindle_count
A 4-core instance on local NVMe lands around 8 to 16 server connections. That sounds low until you remember those backends are not waiting on each other — they wait on I/O, locks, or fast local storage. Connections above that point add queue depth, memory pressure, and lock contention, not throughput. Verify in production instead of trusting the formula:
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE state = 'active'
GROUP BY 1, 2
ORDER BY 3 DESC;
If active backends sit mostly in ClientRead, the pool is too large and you are holding connections through application think-time. If they sit in IO or LWLock waits, pool size is not your problem — the plan or the storage is, and adding backends makes it worse.
The decision rule
- RDS Proxy — Lambda, Fargate, or fleets without guaranteed sidecars; IAM auth with no passwords in application code; failover without custom scripting; connection counts in the hundreds.
- PgBouncer — latency matters enough to put the pooler on the same host; you need session or statement pooling; a per-vCPU charge is a real line item; you run non-AWS or self-managed Postgres.
- Neither — under roughly 100 connections with no burst pattern. A pooler adds a component to page on without buying anything.
Measure three things before and after: peak-to-idle connection ratio, p99 connection acquisition time, and the wait-event profile of active backends. Pooling does not create capacity — it removes connection overhead that was wasting capacity you already paid for.
