SyncTrix logoSyncTrix
All articles
Platform10 min read

Connection pooling in Postgres: PgBouncer modes and what each one breaks

Transaction pooling gives you the most connections and silently breaks prepared statements, advisory locks and session state. Here's what each mode costs you.

By Priya Iyer
Connection pooling in Postgres: PgBouncer modes and what each one breaks

Every Postgres connection is an operating system process with real memory overhead, so past a few hundred you spend more on connection management than on queries. A pooler fixes that, but the mode you choose determines which Postgres features silently stop working - and the failures are intermittent, which makes them expensive to diagnose. Sizing and operating this layer is part of our cloud and infrastructure work.

01Why connections are expensive

Postgres forks a backend process per connection. Each carries its own memory for work areas, catalogue caches and temporary buffers, so idle connections are not free - they consume memory that could be serving queries and add scheduling overhead.

Serverless platforms make this dramatically worse. A function that opens a connection per invocation and exits without closing cleanly leaves the server holding connections that no client is using, and a traffic spike exhausts the limit almost immediately.

02The three modes and what they break

The mode determines when a server connection is returned to the pool, and therefore how much client state can survive between statements. More aggressive reuse means more clients per server connection and less state you can rely on.

ModeConnection releasedClients per server connBreaks
SessionOn client disconnect1:1 - little gainNothing
TransactionAt transaction endVery highSession state, some prepared statements, cross-statement advisory locks
StatementAfter each statementHighestMulti-statement transactions entirely
PgBouncer pooling modes

03Transaction mode is the right default, with caveats

Most application workloads are short transactions with no session state, which is exactly what transaction mode is designed for. It is the mode that delivers the large connection multiplier people adopt pooling for.

What it breaks is specific: session-level SET commands that outlive a transaction, advisory locks held across statements, LISTEN/NOTIFY, and temporary tables expected to persist. Prepared statements are the awkward one - older setups broke outright, newer PgBouncer versions handle them, and whether yours works depends on version and driver configuration rather than on theory.

04Sizing the pool

The instinct is to make the pool large. The opposite is usually correct: a pool bigger than the database can service converts a fast queue in the pooler into contention inside Postgres, where it is far more expensive. A useful starting point is a small multiple of available cores, then tune against measured wait time.

Watch pool wait time rather than utilisation. Clients waiting a few milliseconds is healthy; clients waiting hundreds of milliseconds means the pool or the database is undersized, and which one it is determines whether you raise the pool or fix the queries.

  • Start from core count, not from client count.
  • Alert on pool wait time, not pool utilisation.
  • Separate pools for background jobs and interactive traffic.
  • Cap per-application pools so one service cannot starve the rest.

05The application-side mistake

Running an application-level pool behind a server-side pooler is common and frequently misconfigured. If every one of twenty application instances holds a pool of twenty, you are asking for four hundred connections regardless of what the pooler allows.

Application pools should be small when a pooler sits in front - the pooler is doing the multiplexing. Two layers each sized as though they were the only one is how teams exhaust connections while believing they are pooling correctly.

06Diagnosing exhaustion

When connections run out, check what they are doing before adding more. Sessions sitting in idle-in-transaction are the usual culprit - an application opened a transaction, did something slow that was not database work, and never committed. Those hold locks and block vacuum while contributing nothing.

Set a timeout for idle transactions so the database reclaims them rather than waiting for an application that may never return. It converts a slow outage into a clear application error, which is much easier to fix.

  • Inspect pg_stat_activity for idle-in-transaction sessions first.
  • Set idle_in_transaction_session_timeout as a backstop.
  • Never do network calls inside an open transaction.
  • Add connections only after ruling out held transactions.

Topics

pgbouncer transaction poolingpostgres connection poolingconnection pool exhaustionpostgres max connectionsserverless postgres connectionspgbouncer session mode

Priya Iyer

Staff Engineer · SyncTrix

Writes about the engineering decisions behind production systems - architecture, delivery and the trade-offs that only show up at scale.

Building something like this?

SyncTrix engineers AI, SaaS, platform and cloud systems for enterprises and high-growth teams. Tell us what you're shipping and we'll scope it with you.