PostgreSQL Connection Pooling: Why max_connections=200 is Killing Your Performance
Why bumping max_connections makes things worse, how PgBouncer transaction mode works, the work_mem multiplier trap, and the prepared statement gotcha that breaks Rails and Django.
Your app throws too many clients already. You bump max_connections to 500. It feels like more lanes on a highway, but in Postgres it is more like hiring 500 librarians - they spend all their time talking to each other instead of finding books.
What each connection actually costs
Postgres is process-per-connection. A freshly forked backend uses ~1.5-2 MB RSS due to Linux Copy-on-Write, but once it performs any real work resident memory typically reaches 10-20 MB. 500 processes on 16 cores is a kernel context-switching nightmare - empirical benchmarks show a clear performance inflection point around 128-256 clients. You also hit ProcArray lock contention during snapshot acquisition and transaction commit.
Transaction mode vs session mode
Use PgBouncer in transaction mode. It lets 1,000 clients share 20-30 DB connections. Session mode reduces TCP/TLS handshake overhead but does not reduce the PostgreSQL backend process count - it solves the wrong problem for high-concurrency workloads.
The work_mem multiplier
work_mem is per-operation. A query with 3 sorts and 4 parallel workers uses 12× work_mem:
Total ≈ concurrent_queries
× ops_per_query
× (max_parallel_workers_per_gather + 1)
× work_memNote: the relevant setting is max_parallel_workers_per_gather(workers per query node), not max_parallel_workers (the global cap). Keeping active connections low via a pooler is the only way to safely increase work_mem for heavy queries.
The compatibility trade-off
Transaction mode breaks several protocol-level features. Verify before deploying:
- Prepared statements - the default in Rails (ActiveRecord), many Java drivers, and some Python drivers. You will see
prepared statement does not existerrors immediately after deploy. Fix: disable prepared statements in your ORM, or enablemax_prepared_statementsin PgBouncer 1.21+. - Advisory locks - released at transaction end, not session end. Session-scoped advisory locks silently break.
- SET commands -
SET search_path,SET timezone, and similar session-level settings do not persist across transactions. Addserver_reset_query = DISCARD ALLto yourpgbouncer.inito reset state between clients. - LISTEN/NOTIFY - not supported in transaction mode.
- Port change - applications must connect to PgBouncer on port 6432, not to PostgreSQL on port 5432.
Calculate your max_connections, work_mem, and PgBouncer pool size for your server with the PostgreSQL Connection Pool Sizer.
Related tool
PostgreSQL Connection Pool Sizer →Calculate the optimal max_connections, work_mem, and PgBouncer pool size for your PostgreSQL server. Get a ready-to-paste postgresql.conf snippet.