PRODSovereign European BaaS platformOpen Dashboard →

Performance · 10 min read

PgBouncer transaction pooling explained

Affane Daylami · Fondateur · June 12, 2026

Back to blog

PgBouncer's transaction mode releases the PostgreSQL connection at the end of each transaction, not when the client disconnects. This is what makes it possible to serve thousands of HTTP clients with a few dozen actual server connections, and it's the recommended mode for any short-query REST API.

This English text was generated automatically from the French original and has not been reviewed yet.

This gain has a specific cost: transaction mode silently breaks everything that assumes a stable Postgres connection from one request to the next. Session SET, LISTEN/NOTIFY, advisory locks, cursors that survive the transaction, named prepared statements. This article details the mechanism, lists these limits with their exact symptoms, then shows how a backend in production (ours, verified directly in its repository) configures it without being trapped by it. For the measurement method behind any performance figures cited here, see our benchmark methodology.

The essentials

  • Transaction mode: the server connection is released at the end of each transaction, not when the client disconnects. This is the most efficient mode for sharing short REST API type connections.
  • Incompatible by construction with: session SET/RESET, LISTEN/NOTIFY, session advisory locks, WITH HOLD cursors, temporary tables reused from one request to another.
  • The most common pitfall in practice: named prepared statements, which several drivers (sqlx, asyncpg, the JDBC pgjdbc driver) activate by default, can be replayed on a different server connection and trigger an error like prepared statement does not exist under load.
  • Since version 1.21, PgBouncer can follow protocol prepared statements in transaction mode (LRU cache via server connection). This does not prevent disabling the client-side cache if your application changes search_path on each request.
  • Verified in the Aurabase code: the tenant pools run with statement_cache_capacity(0) and PgBouncer in pool_mode=transaction, while PostgREST voluntarily remains in direct connection for its schema reloading via LISTEN/NOTIFY.
#
Concepts

The 3 pooling modes of PgBouncer

PgBouncer offers three modes, which differ only in when the Postgres server connection returns to the common pool. The official documentation names them session, transaction and statement (pgbouncer.org/features.html, “Pooling modes” section, accessed August 24, 2026).

FashionServer connection looseSession compatibility
session (default)On client disconnectionTotal: SET, LISTEN, cursors, everything works like live
transactionAt the end of each transaction (COMMIT/ROLLBACK)Partial: only what remains local to the transaction
statementAfter each individual requestMinimal: explicit multi-query transactions prohibited

Session mode is the most permissive but the least effective in scalability: a Postgres connection remains reserved for a client as long as it remains connected, even if it does nothing between two requests. Statement mode is reserved for very specific cases (read-only proxy, health-checks) and even breaks classic explicit transactions. Transaction mode is the compromise that dominates in practice for a REST API: each HTTP request generally corresponds to a single short Postgres transaction.

#
Mechanism

How transaction mode works, connection by connection

In transaction mode, PgBouncer only attaches a server connection to a client when the latter opens a transaction, and returns it to the pool upon COMMIT or ROLLBACK. Between two transactions, the same client may find itself reassigned to a completely different server connection.

Concretely, with a default_pool_size of 20, PgBouncer can absorb several hundred simultaneous clients who, at any given time, only have a few transactions actually in progress. It is this ratio which justifies transaction mode for a REST API with high traffic but short transactions: the rare resource (a Postgres connection, expensive in memory on the server side) is only occupied for the time strictly necessary.

pgbouncer.iniini
[pgbouncer]
listen_port = 5432

; La connexion serveur est libérée dès la fin de chaque transaction
pool_mode = transaction

default_pool_size = 80
max_client_conn = 1000

; Ne pas utiliser avec des sessions stateful (SET, advisory locks, LISTEN/NOTIFY)

This last comment sums up the gist: transaction mode works because it deliberately breaks the link between "my application session" and "my Postgres connection". Everything based on this link breaks. The next section lists precisely what.

#
Limits

What breaks in transaction pooling mode

The official PgBouncer documentation explicitly lists PostgreSQL features which lose their meaning as soon as a server connection can be recycled between two requests from the same client.

Affected functionalityWhy does it breakTypical symptom
SET / SET SESSIONThe setting applies to a connection that can be recycled immediately afterA parameter seems to be randomly forgotten between two requests
LISTEN / NOTIFYAssumes a persistent connection to receive notificationsThe client is never notified, or only intermittently
Session advisory locksThe lock is held by the server connection, not the logical clientA lock releases before the expected completion, or never releases
WITH HOLD slidersMust survive beyond the transaction that opened it"cursor does not exist" error on next iteration
Temporary tablesRelated to the Postgres session, not the transactionThe table "disappears" on the next query
Prepared statements namedPrepared on a specific server connection, replayed on another"prepared statement ... does not exist" under load
The trap is not always immediate

Most of these limitations do not manifest themselves in local development, where a single connection typically serves all traffic. They appear under real load, when several clients actually share the pool and a server connection actually changes hands between two requests from the same logical client. A smoke test almost never reveals them.

#
Common trap

Prepared statements: the most misunderstood limit

Most modern Postgres drivers prepare named requests on the protocol side by default, without the application code explicitly requesting it. This is precisely what makes this trap difficult to anticipate.

A protocol prepared statement is named and cached on a specific server connection, at the time of Parse. In transaction mode, this connection can be reassigned to another client between two requests from the same logical client. If the driver then replays the same statement name on a connection where it was never prepared, Postgres responds with an explicit error, typically prepared statement "sqlx_s_N" does not exist for an sqlx client. The behavior is intermittent: it depends on how the connections perform under load, not on a deterministic bug reproducible on each call.

The client-side correction is the same regardless of the language: disable the cache of named prepared statements, or force unnamed queries, for any pool that crosses a pooler in transaction mode. In Rust with sqlx, it goes through statement_cache_capacity(0) on the connection options.

pool.rsrust
let connect_options = url
    .parse::<PgConnectOptions>()?
    .statement_cache_capacity(0);

// Equivalents: asyncpg -> statement_cache_size=0, pgjdbc -> prepareThreshold=0

Since version 1.21, PgBouncer alleviates part of the problem on the server side: it can follow protocol prepared statements in transaction mode and prepare them on the fly on the assigned connection, with an LRU cache per connection whose size is adjusted via max_prepared_statements. This reduces the number of misses, but it does not exempt you from disabling the client cache in a multi-tenant pool where the search_path changes from one request to another: a cached plan freezes the internal identifier (OID) of the resolved table at the time of Parse, and replaying it under another schema may return data from the wrong tenant rather than a simple error.

#
Checked in code

How Aurabase configures PgBouncer in transaction mode

The Aurabase repository deploys PgBouncer as pool_mode=transaction in front of the shared data plane (deploy/helm/aurabase/templates/infra/pgbouncer.yaml), and an identically configured Pooler CNPG in front of each dedicated Postgres instance of a tenant (deploy/cnpg/tenant-pooler.yaml). Both paths apply the same discipline described above.

The source code documents a specific security reason for this choice, not just a stability reason. Postgres pools shared between tenants position a different search_path per project on reused connections. A cached prepared statement freezes the OID of the resolved table at the time of Parse; replaying it for another tenant on the same connection would run the query against the first tenant's schema, an isolation bypass, not just an application error. statement_cache_capacity(0) is therefore applied without exception, including on dedicated instances which also pass through a CNPG Pooler in transaction mode.

Second session hygiene measure: when each connection returns to the pool, a hook executes DISCARD ALL (resetting settings, deallocating prepared statements on the server side, releasing advisory locks, purging cursors and temporary tables). Without this hook, a session residue posed by a previous request could leak on the next request from a different tenant reusing the same recycled connection.

Exception accepted: dedicated PostgREST instances remain in direct connection to the primary, without going through the pooler. PostgREST schema reloading relies on LISTEN/NOTIFY, which assumes a persistent connection, exactly the functionality that transaction mode breaks (detail already documented in our article on PostgREST compatibility at Aurabase). RLS settings per request are passed to SET LOCAL within an explicit transaction, the only way to remain compatible with a pool that can change server connections at any COMMIT (see our article onmulti-tenant RLS isolation).

#
Practical guide

Enable transaction mode without breaking your application

A short checklist, applicable to any backend moving from a direct Postgres connection to a PgBouncer in transaction mode.

  1. Audit the application code. Look for non-transaction SET, LISTEN/NOTIFY, session advisory locks, WITH HOLD cursors and temporary tables reused between queries.
  2. Replace session SETs with LOCAL SETs within an explicit transaction. This is the only setting that properly survives connection recycling, because it is cleaned up at COMMIT/ROLLBACK rather than leaking on the next connection.
  3. Disable the driver-side prepared statements cache if your pool traverses the pooler and the schema or role changes from one request to another. The cost in performance is real but measurable, and much lower than the risk of leakage between tenants.
  4. Isolate connections that really need session mode (migrations, admin scripts, anything that depends on LISTEN/NOTIFY) to a direct non-pooler connection, rather than forgoing transaction mode for all other traffic.
  5. Size default_pool_size and max_client_conn relative to Postgres' actual max_connections, not by an arbitrary figure copied from another project.
  6. Test under real load, not just smoke test. Prepared statement errors and session settings leaks almost never appear on a single local connection.
  7. Monitor SHOW POOLS and SHOW STATS from the PgBouncer administration console once in production, to spot pool saturation before it becomes visible on the client side.
#
Decision

Should you always choose transaction mode rather than session?

No, but it is the correct default choice for the vast majority of REST APIs. Session mode remains preferable for a legacy application heavily dependent on session functionalities that you cannot refactor quickly, or for low traffic where the pooling gain does not compensate for the migration effort.

PgBouncer is not the only implementation of this pooling model either: Supavisor (Supabase) and PgCat are two recent alternatives, with different trade-offs on load distribution and clustering. See our detailed comparison, PgBouncer vs Supavisor vs PgCat, to choose between the three depending on your topology.

#
Frequently Asked Questions

FAQs

The questions that come up most often once transaction mode is activated in production.

What is PgBouncer transaction pooling mode?+
This is one of the 3 modes of PgBouncer (with session and statement) in which the Postgres connection is reassigned to another client at the end of each transaction, rather than when the client disconnects. This makes it possible to serve many more competing clients than there are actually open Postgres connections.
Why do my prepared statements crash in transaction mode?+
A named prepared statement is prepared on a specific server connection. In transaction mode, this connection can be reassigned to another client between two requests. If your driver replays the name of the statement on a connection where it has never been prepared, Postgres returns an error like prepared statement does not exist. The correction consists of disabling the prepared statements cache on the driver side (statement_cache_capacity(0) with sqlx, statement_cache_size=0 with asyncpg).
Can we use LISTEN/NOTIFY behind a PgBouncer in transaction mode?+
No, not reliably. LISTEN/NOTIFY assumes a persistent connection to receive notifications, which transaction mode does not guarantee. Standard practice is to pass components that depend on LISTEN/NOTIFY (PostgREST, for example) through a direct connection to Postgres, outside the pooler.
Should we use SET LOCAL rather than SET in transaction mode?+
Yes, systematically for any setting that must apply to a given request. SET LOCAL is cleaned up automatically on COMMIT or ROLLBACK, making it safe with a server connection that may change between two transactions. A classic SET can leak to the next client which recovers the same recycled server connection.
Does transaction mode work with Row Level Security (RLS)?+
Yes, provided that the JWT claims or session variables used by your RLS policies are set in LOCAL SET inside the transaction, not in session SET. This is the pattern described in our article on multi-tenant RLS isolation.
PgBouncer, Supavisor, PgCat: which one to choose?+
The three implement a similar pooling model, with differences in clustering, load distribution and the ecosystem (Supavisor is developed by Supabase, PgCat is written in Rust). The choice depends above all on your deployment topology and your existing operational constraints: see our dedicated comparison for details.

READY TO DEPLOY?

Your backend in five minutes.

No credit card required · 500 MB free · 50,000 MAU