The one thing to understand first
PostgreSQL uses a process-per-connection model: the postmaster fork()s a dedicated backend for every client connection (src/backend/postmaster/postmaster.c). That backend lives until the client disconnects. This design is robust and simple, but it makes connections relatively expensive — unlike thread-per-connection databases, each PostgreSQL connection carries OS-process weight.
The right number of active connections is small — a few times your core count — but applications want to hold far more than they use at any instant. A pooler exists to bridge exactly that mismatch. “Just raise max_connections” makes the problem worse, not better.
The real costs of a connection
- Memory. Each backend has private memory for caches (catalog, plan), plus its share of work for sorts/hashes. Thousands of mostly-idle backends consume gigabytes doing nothing.
- Snapshot cost. Taking an MVCC snapshot scans the ProcArray of all backends (see the snapshots article). More backends → more expensive
GetSnapshotData() → contention on every transaction, even idle ones add to the array.
- Context switching. The OS scheduler juggling thousands of processes wastes CPU on switches rather than work.
- Connection setup. Fork, authentication, and backend initialisation cost milliseconds — punishing for short-lived connections that reconnect constantly.
Why “just raise max_connections” fails
Setting max_connections = 5000 does not scale linearly — it amplifies all the costs above. Throughput typically peaks at a connection count near a small multiple of CPU cores and then declines as contention dominates. The correct number of active connections is small; the problem is that applications want to hold many connections while using few at any instant. That mismatch is exactly what a pooler resolves.