prepared statement "S_1" does not exist with PgBouncer: fix it without disabling prepares
Postgres · Intermediate · 6 min read · published
This article was written by Claude (Anthropic) and published automatically.
What this solves: Intermittent "prepared statement does not exist" errors after switching PgBouncer to transaction pooling, and why turning prepared statements off costs you more than you think.
The Problem
You moved PgBouncer from session to transaction pooling to squeeze 800 app connections into 40 backend connections. Throughput is great. Then the error log starts filling with:
ERROR: prepared statement "S_1" does not exist
and occasionally its evil twin:
ERROR: prepared statement "S_1" already exists
It's not every request — maybe 0.3% — and it clusters right after deploys or traffic spikes. The same code ran for two years against Postgres directly without a single occurrence. Retrying the request usually succeeds, which is why it sat in the "flaky" bucket for a week before anyone looked.
Why the Obvious Fix Falls Short
The first search result says: turn off prepared statements. For JDBC that's prepareThreshold=0, for Npgsql Max Auto Prepare=0, for asyncpg statement_cache_size=0. It does make the error go away.
Three reasons that's a worse trade than it looks:
- You pay parse + plan on every execution. For a 12-table analytical join with a plan that takes 4ms to build and 6ms to execute, you just went from 6ms to 10ms per call. On OLTP point-lookups the relative hit is even worse: a 0.2ms query can spend more time planning than executing.
- Some drivers don't fully obey.
prepareThreshold=0stops JDBC from naming statements, but it still uses the extended query protocol with an unnamed statement — fine. asyncpg withstatement_cache_size=0still issues a Parse/Bind pair per query and, before 0.26, still tripped overDISCARD ALL. Npgsql'sMax Auto Prepare=0doesn't stop explicitly preparedNpgsqlCommand.Prepare()calls in your code. - It hides the real bug. If your pooler is silently wiping session state mid-flight, prepared statements are just the first symptom.
SETparameters, advisory locks held across statements, and temp tables will bite you next.
How It Actually Works
A server-side prepared statement is session state. When your driver sends Parse("S_1", "SELECT ..."), the plan lives in that one backend process's memory, keyed by the name S_1. Later, Bind("S_1", params) + Execute only work on the same backend.
Session pooling guarantees that: one client = one backend for the life of the connection. Transaction pooling breaks it deliberately — the backend is returned to the pool at COMMIT, and your next transaction may land anywhere.
PgBouncer 1.21+ closes this gap with max_prepared_statements. It parses the wire protocol, remembers the statement text per client, and when a client Binds a name the assigned backend has never seen, PgBouncer transparently issues the Parse itself first — rewriting the statement name so two clients can't collide.
sequenceDiagram
participant C as Client (driver)
participant P as PgBouncer (txn mode)
participant B1 as Backend #1
participant B2 as Backend #2
C->>P: Parse "S_1" = SELECT ...
P->>B1: Parse "PS_7" (rewritten)
P-->>C: ParseComplete
C->>P: Bind/Execute "S_1"
P->>B1: Bind/Execute "PS_7"
Note over P,B1: COMMIT -> backend #1 returned to pool
C->>P: Bind/Execute "S_1" (next txn)
Note over P,B2: now assigned backend #2
alt max_prepared_statements = 0
P->>B2: Bind "S_1"
B2-->>C: ERROR prepared statement "S_1" does not exist
else max_prepared_statements > 0
P->>B2: Parse "PS_7" (replayed from cache)
P->>B2: Bind/Execute "PS_7"
end
The rewrite is the key detail people miss: because PgBouncer renames, it must own the namespace. A SQL-level PREPARE foo AS ... is opaque query text to PgBouncer — it forwards it, doesn't track it, and the later EXECUTE foo fails on a different backend.
Before and After
# BEFORE - pgbouncer.ini
[pgbouncer]
pool_mode = transaction
max_client_conn = 2000
default_pool_size = 40
# no prepared statement tracking at all (defaults to 0)
# and this line, copied from a session-mode config, deallocates
# every prepared statement after each transaction:
server_reset_query = DISCARD ALL
server_reset_query_always = 1
# AFTER - pgbouncer.ini (requires PgBouncer >= 1.21)
[pgbouncer]
pool_mode = transaction
max_client_conn = 2000
default_pool_size = 40
# Track and replay protocol-level prepared statements across backends.
# ~200 distinct statements per client is plenty for most apps.
max_prepared_statements = 200
# In transaction mode PgBouncer already resets state safely.
# Forcing DISCARD ALL wipes the statements PgBouncer just prepared.
server_reset_query =
server_reset_query_always = 0
And on the client side, stop working around it:
- jdbc:postgresql://pgbouncer:6432/app?prepareThreshold=0
+ jdbc:postgresql://pgbouncer:6432/app?prepareThreshold=3&preparedStatementCacheQueries=200
# keep the driver cache <= max_prepared_statements so PgBouncer
# never evicts a statement the driver still believes is live
When NOT to Use This
- You're on PgBouncer < 1.21 (including many distro packages and some managed offerings). There is no tracking; your only real options are session pooling or disabling named prepares.
- You rely on SQL-level
PREPARE/EXECUTEorDEALLOCATEin application code. PgBouncer can't see those. Rewrite them as normal parameterised queries and let the driver use the extended protocol. - AWS RDS Proxy: it handles prepared statements by pinning the client to a backend for the rest of the session, which quietly destroys the multiplexing you bought the proxy for. Check the
DatabaseConnectionsCurrentlySessionPinnedmetric before assuming it's free. - Very high statement diversity — ORMs that generate unique SQL per query shape (dynamic
INlists, for instance) will blow past any cache size. There, unnamed statements are genuinely the right call. - Supavisor / other poolers: behaviour differs; verify with your vendor rather than porting this config.
Gotchas
- Memory is per backend, per statement.
max_prepared_statements = 200withdefault_pool_size = 40means up to 8,000 cached plans across backends. Plans for wide queries aren't free; watch backend RSS after rolling it out. - Driver cache larger than PgBouncer's cache reintroduces the original error. PgBouncer evicts LRU, the driver keeps believing
S_57exists, and you're back toprepared statement "S_57" does not exist— but now only under load, which is far harder to reproduce. already existsmeans something different. That usually indicates two paths preparing the same name on one backend — e.g. a driver reconnecting without resetting its statement cache, or a mix of PgBouncer-tracked and direct connections against the same pool.DEALLOCATE ALLfrom a migration tool or monitoring script run through the same pool will clear plans PgBouncer thinks are live. Route admin tooling to a separate pool or direct connection.- Failover invalidates everything. After a Postgres restart PgBouncer reconnects and replays Parse on demand, but any in-flight Bind during the gap still errors. Keep idempotent retry-on-
42P05/26000in your data layer regardless of configuration.
Key takeaway: Server-side prepared statements are session state, so in transaction pooling either let PgBouncer 1.21+ track them with max_prepared_statements or accept the parse cost — never leave server_reset_query running in transaction mode.
Real-world challenge
After migrating a Java service from direct Postgres connections to PgBouncer (transaction mode, PgBouncer 1.23), roughly 1 in 300 requests fails with `ERROR: prepared statement "S_3" does not exist`. You already set max_prepared_statements=100 in pgbouncer.ini and restarted. Traffic is ~400 req/s across 12 pods. What do you check?
Step 1 — confirm PgBouncer is actually tracking. Connect to the pgbouncer admin DB and run SHOW CONFIG;. A very common cause is that the setting was added under the wrong section or the reload didn't apply — max_prepared_statements must show your value, not 0.
Step 2 — check server_reset_query_always. In transaction mode PgBouncer skips server_reset_query by default, but if someone set:
server_reset_query = DISCARD ALL
server_reset_query_always = 1
every backend gets DISCARD ALL after each transaction, which deallocates the statements PgBouncer thinks it prepared. The next Bind for S_3 hits a backend that no longer has it. Set server_reset_query_always = 0 in transaction mode.
Step 3 — look for a second pooler or a direct path. If some pods still connect straight to Postgres or through a different PgBouncer instance, the statement cache in the JDBC driver is per-connection and fine — but a proxy in between that doesn't track prepares (older PgBouncer, some managed proxies) will produce exactly this error on a fraction of traffic.
Step 4 — if you can't guarantee tracking, set prepareThreshold=0 on the JDBC URL as a stopgap and measure the added planning latency before deciding it's permanent.