Watch connections queue and fail without a pool. Drag load to see pool exhaustion in real time. Step through PgBouncer's three modes and understand which breaks prepared statements.
PostgreSQL allocates ~5-10MB per connection. A connection includes a dedicated backend process (fork), memory for query buffers, lock tables, and statistics. 500 connections = 2.5GB just in connection overhead — before any queries run. And establishing a new TCP+TLS+auth handshake takes 50-100ms.
Many tutorials suggest pool_size = num_workers × 10. For 50 app servers × 10 workers × 10 = 5000 connections → PostgreSQL OOM. The formula is: pool_size = (core_count × 2) + effective_spindle_count per database server. For a 4-core Postgres instance: (4 × 2) + 1 = 9 connections per app server. With 50 app servers: 450 total — manageable.
PgBouncer sits between your app and Postgres, multiplexing many app connections onto fewer DB connections. The mode determines when a DB connection is released back to the pool. Each mode has compatibility trade-offs.
| Mode | Connection Held | Multiplexing | Transactions | Prepared Stmts | SET params |
|---|---|---|---|---|---|
| Session | Full session | None (1:1) | ✓ Full | ✓ Yes | ✓ Yes |
| Transaction | During txn only | High | ✓ Yes | ✗ Broken | ✗ Broken |
| Statement | Single statement | Highest | ✗ Broken | ✗ Broken | ✗ Broken |
Most ORMs (SQLAlchemy, Django ORM, Sequelize) use prepared statements and SET search_path per connection. In transaction mode, these run on a different DB connection each time — SET parameters get lost, prepared statements fail. Fix: either use session mode, or configure the ORM to not use prepared statements (disable_server_side_cursors=True in SQLAlchemy, prepareThreshold=0 in psycopg).
A read replica receives writes asynchronously from the primary via WAL (Write-Ahead Log) replication. During high write load, the replica falls behind — called "replication lag." A read immediately after a write might return stale data from the replica.
User updates profile → write to primary → immediately reads back → routed to replica → gets stale data. Fix strategies: 1. Route the same user's reads to primary for 1s after a write. 2. Read from primary for session-critical operations. 3. Pass a replication LSN token — replica only serves reads after applying that LSN.
PgBouncer doesn't route reads/writes — it's a connection pool, not a query router. For read/write splitting, use Pgpool-II, ProxySQL (MySQL), or application-level routing. Application-level: check query for SELECT vs INSERT/UPDATE/DELETE and pick the connection pool accordingly.
Pool size isn't "as many as possible." Too many connections hurts Postgres as much as too few. The right pool size is derived from how your DB spends its time — CPU-bound queries and I/O-bound queries have different optimal concurrencies.
Aggregations, complex JOINs, report queries. Optimal concurrency = number of CPU cores. Adding more connections doesn't help — they just context-switch and fight for CPU. Pool size = db_cores × 2. For a 4-core RDS instance: 8 connections.
Simple lookups, index scans, waiting on disk. CPU is idle while I/O completes — more concurrency helps. Pool size = db_cores × 2 + disk_spindles. For NVMe SSD (treat as 1 spindle): same as CPU-bound. For HDD RAID-10 with 8 spindles: cores × 2 + 8.
From HikariCP's documentation (the authoritative Java pool): pool_size = (core_count × 2) + effective_spindle_count. This formula comes from measuring Postgres under real load. For managed DBs (RDS, Cloud SQL): treat spindle count as 1 (SSDs). 8-core RDS → 17 connections.
If you have 50 app servers each with 17-connection pools: 850 total connections. Postgres default: max_connections = 100. You must configure max_connections = 1000+ and PgBouncer pools to avoid "too many clients" errors. PgBouncer itself uses ~10 conns per pool, multiplexing thousands of app connections onto it.
pool_mode = transaction — for OLTP workloads (set ORM to disable prepared statements)
max_client_conn = 5000 — app connections to PgBouncer (cheap, no Postgres overhead)
default_pool_size = 20 — actual DB connections per database
max_db_connections = 100 — hard cap on total DB connections regardless of pools
server_idle_timeout = 600 — close idle DB connections after 10min
client_idle_timeout = 0 — keep app connections open indefinitely (app manages lifecycle)