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:

  1. 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.
  2. Some drivers don't fully obey. prepareThreshold=0 stops JDBC from naming statements, but it still uses the extended query protocol with an unnamed statement — fine. asyncpg with statement_cache_size=0 still issues a Parse/Bind pair per query and, before 0.26, still tripped over DISCARD ALL. Npgsql's Max Auto Prepare=0 doesn't stop explicitly prepared NpgsqlCommand.Prepare() calls in your code.
  3. It hides the real bug. If your pooler is silently wiping session state mid-flight, prepared statements are just the first symptom. SET parameters, 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

Gotchas

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.