Articles

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.

The Lycoris Team The Lycoris Team · · 4 min read
PostgreSQL elephant logo illustration

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

SessionTransactionStatement
Connection held forWhole client sessionOne transactionOne statement
Max client concurrency per real connectionLowestHighHighest
Multi-statement transactionsYesYesNo
Session-level features (prepared statements, SET, advisory locks)Fully supportedUnreliable across transactionsNot supported
Typical useLow-traffic apps, admin toolsMost production web/API workloadsNarrow, 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:

  • SET statements 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/NOTIFY rely 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.

Chisato 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.

#Databases #Cloud #Performance
The Lycoris Team 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.

#Databases #SQL #Performance
The Lycoris Team 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.

#Databases #Performance #SQL