Connection Pooling & Plan Cache: PgBouncer and Per-Backend Reuse #
A connection pooler multiplexes hundreds of client connections onto a handful of PostgreSQL backends, and that multiplexing quietly reshapes everything PostgreSQL caches per backend: protocol-level prepared statements, the plan cache entries behind them, and session GUCs. The plan reuse you carefully arranged in the driver can evaporate the moment a pooler hands your next transaction to a different backend.
This page explains how PgBouncer’s three pool modes interact with those per-backend caches, how to detect defeated plan reuse, and how to decide between protocol-level prepared statements and client-side simple queries. It is part of the broader runtime environment picture of why an app’s plan diverges from psql.
Pool Modes and What They Preserve #
PgBouncer offers three pool modes, and the choice governs how long a client keeps a backend — which in turn governs what per-backend state survives.
- Session mode — a client is assigned a dedicated backend for the entire life of its client connection. Prepared statements, the plan cache, session GUCs, temporary tables, and advisory locks all persist normally. This behaves like a direct connection, at the cost of needing as many backends as concurrent clients.
- Transaction mode — a backend is assigned only for the duration of a transaction and returned to the pool at
COMMIT/ROLLBACK. This is where density comes from: a few dozen backends can serve thousands of clients. But server-side named prepared statements do not survive, because your next transaction may run on a different backend. - Statement mode — a backend is released after every single statement; multi-statement transactions are forbidden. It is the most aggressive and the most restrictive.
The trap is transaction mode, because it is the default recommendation for high connection counts and it silently breaks server-side prepared statements unless you take extra steps.
Per-Backend Prepared Statements and the Plan Cache #
A server-side prepared statement — created either by an explicit PREPARE or, far more commonly, by your driver using the extended query protocol (Parse/Bind/Execute) — is a named object owned by exactly one backend process. Behind that name sits a plan cache entry that PostgreSQL uses to skip re-parsing and re-planning. Both the named statement and its cached plan are private to that backend.
Under transaction-mode pooling, that ownership is the whole problem. Observe it directly:
-- Issued on a pooled connection right after the driver "prepared" 40 statements.
SELECT name, statement, generic_plans, custom_plans
FROM pg_prepared_statements;
name | statement | generic_plans | custom_plans
------+-----------+---------------+--------------
(0 rows)
-- The named statements were prepared on a DIFFERENT backend than the one
-- serving this SELECT. This backend's plan cache never saw them.
Because the plan cache lives per backend, cache state — including whether a statement has crossed the five-execution threshold into a generic plan — varies from transaction to transaction. One transaction lands on a warm backend that has executed the statement six times and pinned a generic plan; the next lands on a cold backend that re-plans a fresh custom plan. The behavior described in prepared statement plan pinning becomes non-deterministic under pooling, because the execution counter that drives it is per backend.
To see the actual executed plan under these conditions, auto_explain is again the tool, since the client never surfaces which plan ran:
LOAD 'auto_explain';
SET auto_explain.log_min_duration = 0; -- capture everything (test only)
SET auto_explain.log_analyze = on;
-- run the workload through the pooler; read plans from the server log,
-- noting whether Index Cond shows a literal (custom) or $1 (generic).
Resource Behavior: Connection Churn Defeats Plan Reuse #
The performance cost of defeated reuse is concrete: planning time is paid on every execution instead of once. Parsing and planning a moderately complex join can cost as much as executing it for a selective query, so re-planning on each call can double effective latency without a single row changing.
Connection churn — clients rapidly acquiring and releasing pooled backends — makes this worse by ensuring no single backend accumulates enough executions of a statement to benefit from its cache. Measure the impact directly:
-- Compare planning cost to execution cost per statement.
-- A high plan-to-exec ratio on a hot query signals defeated reuse.
SELECT queryid,
calls,
round(total_plan_time::numeric, 1) AS plan_ms,
round(total_exec_time::numeric, 1) AS exec_ms,
round(total_plan_time / nullif(total_exec_time,0), 2) AS plan_exec_ratio
FROM pg_stat_statements
WHERE calls > 100
ORDER BY plan_exec_ratio DESC NULLS LAST
LIMIT 15;
queryid | calls | plan_ms | exec_ms | plan_exec_ratio
---------+-------+----------+---------+-----------------
88213 | 42000 | 512340.2 | 61020.5 | 8.40
-- plan time is 8.4x exec time: this statement is re-planned on almost
-- every call. With reuse it would be planned once and this column ~0.
total_plan_time is only populated when track_planning is on. A ratio well above 1 on a high-calls statement is the clearest signal that pooling is preventing the plan cache from doing its job. The underlying planning work itself is governed by the cost estimation models — pooling does not make each plan more expensive, it just forces you to pay for that estimation repeatedly.
Numbered Diagnostic Workflow #
Follow these steps when a query behind a pooler plans differently, or more often, than it should.
Step 1 — Check the pool mode.
; pgbouncer.ini
[databases]
appdb = host=127.0.0.1 dbname=appdb pool_mode=transaction
-- Or query it live from the PgBouncer admin console:
SHOW DATABASES; -- inspect the pool_mode column for appdb
If pool_mode = transaction, server-side prepared statements are at risk unless you have explicitly enabled support for them.
Step 2 — Check server_reset_query.
; A DISCARD ALL here wipes prepared statements, GUCs, and temp tables
; every time a backend returns to the pool.
server_reset_query = DISCARD ALL
SHOW CONFIG; in the admin console shows the effective value. A DISCARD ALL reset guarantees no per-backend state carries across transactions. The session reset and DISCARD ALL effects page details exactly what each reset variant clears.
Step 3 — Measure planning time.
Run the pg_stat_statements query above. If plan_exec_ratio is high on your hot statements, reuse is defeated and Step 4 applies.
Step 4 — Decide: protocol-level prepared statements or client-side simple query.
- Protocol-level prepared statements — run PgBouncer 1.21+ with
max_prepared_statementsset to a positive value. PgBouncer then tracks each client’s prepared statements and replays theParseon whichever backend serves the next transaction, restoring reuse under transaction mode. - Client-side simple query — configure the driver to send fully-formed SQL with inlined literals (JDBC
prepareThreshold=0, or equivalent) instead of server-side prepared statements. You lose plan caching but gain a custom plan on every call, which is often faster for skew-sensitive queries.
Choose protocol-level support when planning time dominates; choose client-side simple query when generic-plan misfires dominate.
Common Pitfalls #
DISCARD ALL wipes prepared statements every transaction.
Diagnostic: pg_prepared_statements is empty and planning time never amortizes. Fix: either remove the reliance on server-side prepared statements, or use PgBouncer’s prepared-statement tracking so the statements are replayed after the reset.
Transaction-mode pooling plus a JDBC server-prepared driver.
Diagnostic: intermittent prepared statement "S_3" does not exist errors under load. Fix: set the driver to disable server-side statements (prepareThreshold=0 for pgJDBC) or enable PgBouncer 1.21+ prepared-statement support so the statement follows the client across backends.
Cold plan cache after every checkout.
Diagnostic: the first execution on each newly checked-out backend is slow, then fast, in a repeating sawtooth. Fix: this is the plan cache warming from cold on each backend; increase backend affinity with a larger min_pool_size, or accept it and ensure the cold plan is still acceptable.
SET not persisting into the next transaction.
Diagnostic: a SET work_mem issued in one call has no effect on the next query. Fix: transaction-mode reset (or a different backend) discarded it; pin the value with ALTER ROLE ... SET or ALTER DATABASE ... SET instead of a runtime SET.
Frequently Asked Questions #
Why is pg_prepared_statements empty on a pooled connection? #
Server-side prepared statements live inside one backend process. A transaction-mode pooler routes each transaction to whichever backend is free, so a statement your client prepared earlier exists on a different backend than the one now serving you. From the current backend’s view no statements were prepared, so pg_prepared_statements returns zero rows even though the application believes it has prepared many. This is expected behavior, not a bug, and it is the root cause of most pooler plan-reuse surprises.
Which PgBouncer pool mode should I use with prepared statements? #
Session mode preserves prepared statements and session GUCs because each client keeps a dedicated backend for the life of its connection. Transaction mode gives far better connection density but breaks server-side prepared statements unless you run PgBouncer 1.21 or later with max_prepared_statements set, which tracks and replays prepared statements per backend. Statement mode disallows multi-statement transactions and is the most restrictive of the three. See PgBouncer transaction mode and prepared statements for the detailed trade-offs.
Does connection pooling increase planning time? #
It can. When pooling defeats prepared-statement reuse, every execution is parsed and planned from scratch instead of reusing a cached plan, so planning time is paid on every call. You can measure the effect directly in pg_stat_statements by comparing total_plan_time to total_exec_time; a high plan-to-exec ratio on a frequently called query is the signature of defeated plan reuse. The fix is to restore reuse or to accept custom plans deliberately.
Why do my SET commands not persist across queries behind a pooler? #
In transaction mode the pooler runs server_reset_query, usually DISCARD ALL, when it returns a backend to the pool, which clears session GUCs, prepared statements, and temporary tables. Even without a reset query, your next transaction may land on a different backend that never saw your SET. Pin settings with ALTER ROLE or ALTER DATABASE instead of relying on a runtime SET surviving, so the value is applied deterministically on every backend.
Related #
- PgBouncer Transaction Mode and Prepared Statements — the exact failure modes and the 1.21+ support that fixes them
- How DISCARD ALL and Session Reset Affect Plan Reuse — what each reset variant clears and why it matters for caching
- Prepared Statement Plan Pinning — the custom-to-generic lifecycle that pooling makes non-deterministic
- Session Parameter Drift — why SET does not survive and how to pin GUCs deterministically
- Cost Estimation Models — the planning work you pay for repeatedly when reuse is defeated