Postgres Connection Pooling with PgBouncer
26 Jan 2026“FATAL: too many connections” doesn’t mean your database is overloaded. It usually means your app is opening far more connections than it needs, and each one is idle most of the time. PgBouncer fixes this by sitting between your app and Postgres and reusing connections instead of opening new ones.
Why you run out in the first place
Postgres’s default max_connections is 100. That sounds like a lot until you count real sources: a Next.js app with serverless functions can open a new connection per invocation; a Node app with an undersized pool config opens more than you configured; run a few background workers and a couple of admin tools, and you’re at 100 fast — even though at any given moment, almost none of those connections are doing real work.
Each Postgres connection also costs real memory (several MB, depending on config) whether it’s active or idle. A hundred mostly-idle connections is a hundred times that overhead for no throughput benefit.
What PgBouncer actually does
PgBouncer maintains a small pool of real connections to Postgres and hands them out to your app on demand, returning them to the pool when a query finishes instead of closing them. Your app can open hundreds of “connections” to PgBouncer; PgBouncer might only be holding 20 real connections open to Postgres underneath.
flowchart LR
A1[App instance 1] --> PB[PgBouncer\npool_size=20]
A2[App instance 2] --> PB
A3["App instance N\n(hundreds of client conns)"] --> PB
PB --> PG["Postgres\n~20 real connections"]
# pgbouncer.ini
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb
[pgbouncer]
listen_port = 6432
listen_addr = *
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
default_pool_size = 20
max_client_conn = 500
Point your app at PgBouncer’s port (6432) instead of Postgres directly (5432). Nothing else about your app’s SQL needs to change.
Pool mode is the setting that actually matters
| Mode | Connection returned to pool | Use when |
|---|---|---|
session |
After client disconnects | You need session-level features (prepared statements across queries, LISTEN/NOTIFY, advisory locks) |
transaction |
After each transaction commits | Standard web app query patterns — the right default for most apps |
statement |
After each statement | Rarely needed; breaks multi-statement transactions entirely |
transaction mode is what gives you the real multiplexing win — many app-level connections sharing a small pool of real ones, because each is only held for the duration of one transaction, not the whole app session.
The tradeoff: in transaction mode, you can’t rely on session state persisting across queries — no session-level SET statements expected to survive, no WITHOUT HOLD cursors across transactions, no session-scoped temp tables assumed to still exist on the next query. Most ORMs and query builders are fine with this; some older code that assumes a persistent session (prepared statement caching at the driver level, in particular) needs adjustment.
Sizing default_pool_size
This is not “as many as Postgres allows.” It’s closer to (number of CPU cores × 2) + effective spindle count as a starting heuristic — for most cloud Postgres on SSD, that’s roughly 10-30 connections total being genuinely useful, regardless of how many app instances are contending for them. Going higher usually doesn’t increase throughput; Postgres itself context-switches worse past a certain concurrent connection count, so an oversized pool can make things slower, not faster.
Start conservative (10-20), watch pg_stat_activity and your query latency, and increase only if you see connections actually queuing under real load — not preemptively.
Verify it’s working
-- Real connections, should stay low and stable
SELECT count(*) FROM pg_stat_activity;
# PgBouncer's own admin console
psql -p 6432 -U pgbouncer pgbouncer -c "SHOW POOLS;"
SHOW POOLS shows you client connections waiting (cl_waiting) vs. active server connections (sv_active). Waiting clients under normal load means your pool is undersized; increase default_pool_size a bit and re-check.
The takeaway
Point your app at PgBouncer instead of Postgres directly, use transaction pool mode for normal web workloads, and size the pool from your actual CPU/IO capacity, not from max_connections. This fixes “too many connections” without touching Postgres’s own connection limit at all.