The one thing to understand first
PostgreSQL allocates an operating-system process and a chunk of memory for every connection. A few hundred idle connections is not free — it is hundreds of processes and gigabytes of RAM. So a managed instance has a firm max_connections ceiling tied to its size, and modern applications — serverless functions, autoscaling app fleets, microservices — routinely try to open far more connections than that ceiling allows. The fix is a connection pooler.
Why the process model forces pooling
Unlike databases that use lightweight threads per connection, Postgres forks a backend process per session. That design is robust and simple but expensive at high connection counts: memory pressure, scheduler overhead, and contention all rise. Raising max_connections is not a free workaround — each slot reserves resources whether used or not. The scalable answer is to put a pooler between the app and the database so thousands of client connections share a small number of real database backends.
Transaction vs session pooling
Poolers like PgBouncer run in transaction mode (a database backend is handed to a client only for the duration of a transaction, then returned) or session mode (a backend is held for the whole client session). Transaction mode multiplexes far more clients onto few backends and is the workhorse for serverless and high-fan-out apps — but it forbids session-scoped features (some prepared statements, session SETs, advisory locks held across statements). Know which mode you are in; it changes what your app may do.