PgBouncer and Postgres Pooling Modes Explained
PgBouncer sits between clients and Postgres, reusing a small pool of real connections. Session, transaction, and statement modes trade features for scale.
PgBouncer is a lightweight connection pooler that sits between application clients and a PostgreSQL server, holding open a small number of real database connections and handing them out to clients as needed instead of letting every client open its own. It exists because Postgres connections are relatively expensive: each one gets its own backend process with real memory overhead, so a database that’s fine serving a few hundred connections can fall over under a few thousand, even if each connection is mostly idle.
Why pooling matters even with connection pooling already in your app framework
Most application frameworks and ORMs already maintain their own client-side connection pool. That helps within a single application instance, but it doesn’t help across instances — ten replicas of a service, each holding a pool of twenty connections, still add up to two hundred real backend connections on the database, most of them idle most of the time. This is the same scaling pressure discussed in connection pooling vs serverless connections, where short-lived serverless functions make the problem worse by opening and tearing down connections constantly. PgBouncer addresses it by pooling at the infrastructure level, between all of those application instances and the database, so the number of real Postgres connections stays bounded regardless of how many clients are asking for one.
For general background on the underlying mechanism, see what is connection pooling.
The three pooling modes
PgBouncer’s pooling behavior is configured per mode, and the choice determines how long a client “owns” a real server connection.
Session pooling. A client is assigned a server connection for the entire duration of its session — from connect to disconnect — the same as connecting directly to Postgres. This is the safest mode: every Postgres feature works exactly as expected, including session-level settings, prepared statements, and advisory locks. It’s also the least efficient, since an idle client still occupies a real connection the whole time it’s connected.
Transaction pooling. A server connection is assigned only for the duration of a single transaction, then returned to the pool the moment the transaction commits or rolls back — even if the client’s session stays open. This is the most common mode in production, because it lets a small pool of real connections serve a much larger number of concurrent clients; most application traffic is bursty; a client is only actually using the database for the length of one transaction, not the whole time it happens to be connected. The tradeoff is that session-level state doesn’t reliably persist between transactions, since a client’s next transaction might land on a completely different server connection.
Statement pooling. A server connection is held only for a single statement, released immediately after. This is the most aggressive mode and the most restrictive — it doesn’t support multi-statement transactions at all, since there’s no guarantee consecutive statements share a connection. It’s rarely used outside very specific workloads that are pure single-statement queries.
Comparing the modes
| Session | Transaction | Statement | |
|---|---|---|---|
| Connection held for | Whole client session | One transaction | One statement |
| Max client concurrency per real connection | Lowest | High | Highest |
| Multi-statement transactions | Yes | Yes | No |
Session-level features (prepared statements, SET, advisory locks) | Fully supported | Unreliable across transactions | Not supported |
| Typical use | Low-traffic apps, admin tools | Most production web/API workloads | Narrow, statement-only workloads |
What breaks in transaction mode
Transaction pooling is the popular default because it’s the best fit for typical web application traffic, but it changes some assumptions that hold under a direct Postgres connection:
SETstatements and session variables may not persist, since the next statement could execute on a different underlying connection.- Prepared statements need care — a statement prepared on one connection isn’t necessarily available on the next one a client is handed. Modern PgBouncer versions and client drivers have mitigations, but it’s a common source of confusing errors when migrating an application onto transaction-mode pooling for the first time.
- Advisory locks and
LISTEN/NOTIFYrely on a stable, dedicated connection and generally don’t work correctly under transaction pooling. - Session-scoped temporary tables can behave unexpectedly if code assumes they persist across what it thinks is one session but is actually spread across several backend connections.
None of this makes transaction mode wrong — it’s the right choice for the vast majority of stateless request/response workloads — but it does mean an application can’t treat the pooled connection as equivalent to a direct one. Anything relying on connection-scoped state needs a workaround, or a dedicated direct connection outside the pool.
Where PgBouncer fits relative to read replicas
PgBouncer solves a different problem than horizontal read scaling. A read replica adds more database capacity by copying data to additional servers; PgBouncer reduces how many real connections a single Postgres server (primary or replica) needs to maintain in the first place. The two combine naturally — pooling in front of both a primary and its replicas — but pooling alone doesn’t address the throughput or replication lag concerns that come with scaling reads across multiple servers.
The takeaway
PgBouncer reduces the number of real connections a Postgres server has to maintain by pooling them between many clients. Session mode is the safest and least scalable, transaction mode is the practical default for most application workloads but breaks connection-scoped features, and statement mode is the most aggressive and rarely appropriate outside narrow single-statement use cases. Choosing a mode is really choosing how much session-level state your application is willing to give up in exchange for serving far more clients per real database connection.
Tagged
Keep reading
Chisato · · 4 min read What Is Replication Lag? Causes and Fixes
Replication lag is the delay between a write on the primary database and its arrival on a replica. What causes it, how to measure it, and how to reduce it.
The Lycoris Team · · 5 min read Clustered vs Non-Clustered Index Explained
A clustered index determines the physical row order on disk; a non-clustered index is a separate lookup structure. How they differ and when to use each.
The Lycoris Team · · 4 min read The N+1 Query Problem: What It Is and How to Fix It
The N+1 query problem fires one query per row instead of one query total, quietly turning a fast page into hundreds of round trips. How to spot and fix it.