Picking a PgBouncer pool mode is really answering one question: how many clients are allowed to share one PostgreSQL backend's session state? Session mode says one, for as long as the client stays connected. Transaction mode says a fresh one every transaction. Statement mode says a fresh one every statement. The throughput difference is what everyone talks about. What bites is whether your ORM, your driver, or one stray SET search_path quietly depends on state that outlives a transaction boundary. PgBouncer ships an SQL feature map marking every such feature "Never" under transaction pooling. Read it before anything else, including this.
The current release is 1.25.2, dated 2026-05-08, fixing four CVEs (CVE-2026-6664 through CVE-2026-6667): an unauthenticated remote crash via an integer overflow parsing a malformed SCRAM packet, a stack overflow in SCRAM client-final-message construction, a null-pointer crash on an error response lacking SQLSTATE, and a missing authorization check on the KILL_CLIENT admin command. If you are behind that, upgrade before tuning anything.
What each mode actually cuts on
The docs define the three modes in about thirty words, making the semantics easy to skim past:
- session — the server connection returns to the pool when the client disconnects. The default, and it supports every PostgreSQL feature.
- transaction — the server connection returns when the transaction finishes.
- statement — the server connection returns when the query finishes, and transactions spanning multiple statements are disallowed outright.
That last clause is the one people misread. Statement mode is not "transaction mode, but more aggressive" — it actively rejects multi-statement transactions. The documented target is PL/Proxy, forcing the client into autocommit. Unless you are building something in that shape, it has no business in front of an application database.
The escape hatch almost nobody uses
pool_mode is not a single global decision. It can be overridden per database in [databases] and per user in [users], so the right answer to "transaction or session?" is usually "both."
Point the bulk of application traffic at a transaction-mode pool, then define a second [databases] entry — same host, same real database, different alias — running session mode for components that genuinely need session state: advisory-lock workers, anything doing LISTEN, migration tooling, long analytical sessions. Give that alias a small, explicitly capped pool_size. Everything in session mode discards most of the reason you deployed a pooler; everything in transaction mode ships intermittent bugs that only reproduce under load.
The six things that break under transaction pooling
SET / RESET and session GUCs
Marked Never, for a mechanical reason: SET statement_timeout = '5s' lands on whatever backend served that statement, the next transaction may land elsewhere, and the setting evaporates.
PgBouncer is not entirely GUC-blind. It tracks a fixed set of parameters per client and restores them onto whichever server connection the client becomes active on: client_encoding, DateStyle, TimeZone, standard_conforming_strings, application_name, plus IntervalStyle (the default value of track_extra_parameters, which you can extend).
Here is the constraint that trips people up: only parameters PostgreSQL reports to the client — the GUC_REPORT set — can be tracked this way. PostgreSQL 17 and earlier do not report search_path, so the widely repeated advice "just add search_path to track_extra_parameters" does not work on those servers unless an extension changes the reported set; the PgBouncer docs specifically name Citus 12.0+ as causing PostgreSQL to report search_path. PostgreSQL 18 marks search_path as GUC_REPORT, so the tracking does start working there (the breaking changes that come with a major-version jump are catalogued in PostgreSQL 19 Beta Migration) — note that PgBouncer's config reference still carries the older Citus-only wording, while its 1.25.1 changelog already lists PostgreSQL 18 alongside Citus as the setups where this configuration turns up.
That same path is how CVE-2025-12819 worked, fixed in 1.25.1 (2025-12-03): with search_path in track_extra_parameters, an auth_user configured, and a non-schema-qualified auth_query, an unauthenticated attacker could execute arbitrary SQL during authentication. If you added that tracking on a blog post's advice, you now have two reasons to remove it.
The fix for multi-tenant schema routing is SET LOCAL search_path inside the transaction — it dies with the transaction, so it is boundary-aligned — or fully qualified object names. Same for statement_timeout, work_mem, and role. Audit for bare SET in ORM "on connect" callbacks; they are the most common source of this bug, because they were written assuming a connection means a session.
Protocol-level prepared statements: supported now, with conditions
This entry has changed most, and stale advice about it does the most damage. Protocol-level prepared plans are "Yes" under transaction pooling, conditional on max_prepared_statements being non-zero.
The mechanism explains the failure modes. PgBouncer gives each unique query string an internal name of the form PGBOUNCER_{unique_id}, prepares only that name on the real backend, and rewrites commands in flight using its map from the client's chosen name to the internal one. When a client lands on a connection where its statement is not yet prepared, PgBouncer prepares it transparently first. Identical query strings from different clients share one internal name and one server-side plan.
The version timeline matters:
- 1.21.0 (2023-10-16) introduced it, with
max_prepared_statementsdefaulting to 0, i.e. disabled. - 1.24.0 (2025-01-10) flipped the default to 200.
If your pgbouncer.ini was written before 2025 and copied forward, the feature is very likely still off even on a current binary, because an explicit max_prepared_statements = 0 wins over the new default. Check SHOW CONFIG rather than assuming.
SQL-level PREPARE, EXECUTE, and DEALLOCATE remain "Never". They are forwarded to PostgreSQL untouched — no rewriting, no tracking — so they break as they always did. The exceptions are DEALLOCATE ALL and DISCARD ALL, which PgBouncer recognizes and uses to clear the statements it tracked for that client.
The numeric value is per server connection — an LRU cache size on a single backend, not a global ceiling. Raising it costs memory twice: on the PostgreSQL side, because more plans stay prepared per backend, and inside PgBouncer, which must retain the query strings. Set it a little above your application's steady-state count of distinct statements.
Client-side caveats still current: PHP/PDO was incompatible for years (PgBouncer issue #991); the FAQ's position is that it works only with PHP 8.4+ and libpq 17 together — below either, set PDO::ATTR_EMULATE_PREPARES to true. JDBC opts out with prepareThreshold=0. Npgsql, if you keep its own pool in front of a transaction- or statement-mode PgBouncer, requires No Reset On Close=true, because its return-to-pool reset logic is meaningless there.
Temp tables depend on ON COMMIT
The feature map splits this one, and the split is the whole story: ON COMMIT DROP temp tables are "Yes"; PRESERVE ROWS and DELETE ROWS temp tables are "Never".
"You can't use temp tables with transaction pooling" is therefore wrong, and it costs people a useful tool. CREATE TEMP TABLE ... ON COMMIT DROP ends exactly where transaction mode returns the connection, so staging rows inside one transaction — bulk-load, then merge — is safe.
The default is ON COMMIT PRESERVE ROWS, and that is the trap. The table outlives the transaction, but the connection has already gone back to the pool, so the next transaction runs elsewhere and cannot see it — and the table stays attached to the original backend until server_lifetime (default 3600s) or server_idle_timeout (default 600s) recycles it. A correctness bug and a slow leak of temp-schema objects at once.
Session-level advisory locks
Never — the most dangerous entry in the table, because it fails silently.
pg_advisory_lock() and relatives are held until explicitly unlocked or the session ends. Under transaction pooling, the transaction commits, the connection returns to the pool, and the lock stays behind on that backend. The later pg_advisory_unlock() almost certainly runs on a different connection, returns false, and the original lock persists until the backend is recycled minutes later. Nothing raises an error. What you see is a job that occasionally runs twice, or a worker that occasionally blocks for ten minutes on a schedule correlating with server_idle_timeout rather than with anything in your code.
The fix is not config; it is a code change to the transaction-scoped variants: pg_advisory_xact_lock(), pg_try_advisory_xact_lock(), and their _shared forms. These release when the transaction ends and expose no manual unlock at all — which is exactly why they are right here: the API makes the boundary mistake unrepresentable. PostgreSQL implicitly runs pg_advisory_unlock_all() at session end, even on ungraceful disconnect — that is the safety net you lose when a session becomes a pooled resource.
LISTEN, WITH HOLD cursors, LOAD
All three are Never: each establishes state intended to outlive a transaction.
Note the asymmetry that gets misreported constantly: NOTIFY is "Yes"; LISTEN is "Never". Sending completes within the transaction; receiving requires a session that persists. Likewise WITHOUT HOLD cursors are "Yes" — transaction-scoped by definition — while WITH HOLD is "Never".
So any job queue built on LISTEN/NOTIFY needs a session-mode path for its listeners, while its publishers stay on the transaction pool — a handful of long-lived processes against thousands of short requests, exactly the shape the split-alias pattern is for. (For how such a queue absorbs the duplicate deliveries that follow, see The Idempotency-Key Header.)
server_reset_query is not a safety net
A widespread misconception is that leaving server_reset_query = DISCARD ALL at its default makes transaction mode safe. It does not run in transaction mode at all. With server_reset_query_always at its default of 0, the reset query runs only for session-mode pools; the documented reasoning is that transaction-mode clients should not be using session features anyway.
Turning server_reset_query_always = 1 on is not the fix either. The docs are unusually candid: it exists to work around applications using session features over a transaction-pooled PgBouncer, and it converts nondeterministic breakage into deterministic breakage — clients lose state after every transaction, always. A debugging tool, not a remediation.
Know what DISCARD ALL costs, since session-mode deployments often want lighter. PostgreSQL documents it as equivalent to CLOSE ALL; SET SESSION AUTHORIZATION DEFAULT; RESET ALL; DEALLOCATE ALL; UNLISTEN *; SELECT pg_advisory_unlock_all(); DISCARD PLANS; DISCARD TEMP; DISCARD SEQUENCES;. If your session-mode clients only need prepared statements cleared, DEALLOCATE ALL is cheaper and preserves the plan cache.
Counting connections
Do this in two independent directions; most bad deployments compute one number and use it for both.
Backend side: PgBouncer to PostgreSQL
This number is set by what the database can actually execute concurrently, not by how many users you have. The PostgreSQL wiki's starting point for active connections is (core_count × 2) + effective_spindle_count, where core count excludes hyperthread siblings and effective spindle count trends toward zero as the working set fits in cache. The wiki is explicit that the formula held up across years of benchmarks but has not been analyzed for SSDs, and is a starting point for incremental tuning rather than an answer. On a 16-core box with a hot cache that lands near 32 active backends — far fewer than people expect, and precisely why pooling helps at all.
Then do not put that number into default_pool_size and walk away. default_pool_size (default 20) is per user/database pair, overridable by pool_size in [databases] and [users]. Ten databases times five users times twenty is a theoretical thousand backend connections, which hits max_connections long before your tuned target. So:
max_db_connectionscaps server connections per database regardless of user.max_user_connectionscaps them per user regardless of database.
Both default to 0 (unlimited). Set them explicitly in production. Without them, PostgreSQL's max_connections is your only backstop, and reaching it refuses new connections outright — including the one your on-call engineer needs to diagnose the incident. Leave headroom for superuser_reserved_connections.
Frontend side: clients to PgBouncer
max_client_conn defaults to 100 — too low for any real deployment, and the most common cause of "no more connections allowed" on a first rollout. Size it from actual fan-in: application instances × each instance's local pool maximum, plus headroom for overlapping deploys.
Raising it means raising the OS file descriptor limit too. The documented theoretical maximum is max_client_conn + (max pool_size × total databases × total users), or max_client_conn + (max pool_size × total databases) when every client connects under the same user name.
min_pool_size (default 0) keeps connections warm across idle periods, but only for pools with a forced user or a currently connected client. reserve_pool_size with reserve_pool_timeout lends a pool extra connections when clients have queued too long; reserve_pool_timeout defaults to 5s, but reserve_pool_size defaults to 0, so the mechanism stays off until you configure it.
PgBouncer itself
PgBouncer is single-threaded and uses one CPU core per instance. To use more cores, the documented approach is so_reuseport: several instances on the same port, kernel-distributed — effective on recent Linux, FreeBSD (via SO_REUSEPORT_LB), and DragonFlyBSD. The consequence is easy to miss: each instance keeps its own pools, so your real backend ceiling is per-instance limits times instance count. Divide the caps up front.
Telling "pool too small" from "database too slow"
Two columns in SHOW POOLS answer this. cl_waiting counts clients that have sent a query and are still waiting for a server connection; maxwait is how long the oldest client in that queue has waited. A maxwait persistently above zero and trending upward means clients are queuing for server connections — the docs give two candidate causes, an overloaded server or too small a pool_size, and the query timings are what separate them: fast queries plus a growing queue means the pool is too small. SHOW STATS gives the cumulative view via total_wait_time and avg_wait_time, with avg_query_time beside it to separate the two hypotheses. Alert on wait time, not query time.
Two settings from 1.25.0 (2025-11-09) are worth adopting. query_wait_notify (default 5 seconds) sends the client a notice once it has queued that long, making a stall visible in application logs well before query_wait_timeout (default 120 seconds) kills the query. And transaction_timeout (default 0, disabled) disconnects clients sitting in an open transaction — disproportionately valuable here, since a client that opens a transaction and then calls an HTTP API holds a backend connection for the whole round trip, eating shared pool capacity rather than its own.
What I would actually deploy
Transaction mode for the main pool, max_prepared_statements at its 200 default, and a check that the driver emits protocol-level prepares rather than SQL-text PREPARE. A second [databases] alias in session mode, small explicit pool_size, for listeners, advisory-lock workers, and migrations. SET LOCAL instead of SET. ON COMMIT DROP on every temp table. pg_advisory_xact_lock() without exception. max_db_connections and max_user_connections set to real numbers. max_client_conn sized from instance count, file descriptors raised to match. Alerts on maxwait and avg_wait_time.
And skip statement mode unless you are running PL/Proxy — its documented purpose — because it rejects multi-statement transactions by design, not by accident.