Your application starts throwing prepared statement "..." does not exist errors in production. PgBouncer is healthy: no queuing, no wait time, pools well under capacity, PostgreSQL is fast. Single-user testing never reproduces it. Restarting the app makes it go away for a while, then it comes back under load.

This is the pool mode mismatch failure pattern, and it is one of the nastier PgBouncer failure modes because every infrastructure signal looks green. The error is a SQL-level error from PostgreSQL, not a connectivity error from PgBouncer. Nothing in SHOW POOLS or SHOW STATS points at it. The only place it shows up is application error logs.

The mechanism: in transaction pooling mode, PgBouncer returns the server connection to the pool at the end of every transaction. The next transaction from your client may land on a different backend connection. A PREPARE executed on backend A does not exist on backend B. Under concurrency, connections are reassigned constantly, so statements vanish between transactions.

What this means

PostgreSQL prepared statements are session-scoped. They live in the memory of one specific backend process. PgBouncer in transaction mode breaks the 1:1 relationship between a client session and a backend session deliberately: that is the whole point of pooling. The client thinks it has a continuous session. It does not.

flowchart LR
  subgraph client["Client session"]
    T1["Txn 1: PREPARE my_stmt"]
    T2["Txn 2: EXECUTE my_stmt"]
  end
  subgraph pool["PgBouncer pool (transaction mode)"]
    A["Backend A
has my_stmt"] B["Backend B
never saw PREPARE"] end T1 -->|"assigned"| A T2 -->|"reassigned after Txn 1 commits"| B B --> ERR["ERROR: prepared statement
my_stmt does not exist"]

Two things make this hard to catch:

  • Testing passes. With one client and little concurrency, the pool has one server connection and the same backend gets reused every time. The statement is always there. The failure only appears when the pool actually multiplexes, which is exactly what happens in production under load.
  • Failures are intermittent. Whether a given EXECUTE lands on the “right” backend is effectively random. Some requests succeed, some fail, and the failure rate tracks pool turnover rather than anything in your code.

The same root cause breaks every session-dependent feature in transaction mode: temp tables, SET variables, advisory locks, LISTEN/NOTIFY. Prepared statements are just the one ORMs hit first, because most of them use server-side prepares by default.

Common causes

CauseWhat it looks likeFirst thing to check
ORM using server-side prepared statementsErrors mention auto-generated statement names (S_1, pdo_stmt_00000001, __asyncpg_stmt_xx__)Application logs for the statement name pattern; ORM/driver config
Switch from session to transaction pooling without a code auditErrors start the day pool_mode changed; nothing else changedSHOW CONFIG for pool_mode; config change history
SQL-level PREPARE / DEALLOCATE in application codeErrors persist even with max_prepared_statements setGrep application code for PREPARE, EXECUTE, DEALLOCATE
Driver sends DEALLOCATE against renamed statements“does not exist” errors on statement close, even on PgBouncer 1.21+ with tracking enabledDriver name and version (PHP/PDO is the known case)
Unnamed prepared statement reuseunnamed prepared statement does not exist variant of the errorDriver-level protocol behavior; same root cause

Quick checks

All of these are safe and read-only.

# 1. Confirm the pooling mode in effect right now
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer -c "SHOW CONFIG;" | grep -i pool_mode

# 2. Check PgBouncer version (max_prepared_statements needs 1.21+)
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer -c "SHOW VERSION;"

# 3. Confirm pool metrics really are healthy (expected in this failure mode)
psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer -c "SHOW POOLS;"
# Expect: cl_waiting = 0, sv_idle > 0, nothing saturated.

# 4. Find the actual error text and statement names in app logs
grep -o 'prepared statement "[^"]*" does not exist' /path/to/app.log | sort | uniq -c | sort -rn

# 5. On PostgreSQL, see which prepared statements currently exist per backend
# (run on the database, not PgBouncer)
SELECT pid, name, prepare_time FROM pg_prepared_statements;

The statement names from check 4 are the fingerprint. Hibernate generates names like S_1, S_2. PDO generates pdo_stmt_00000001. asyncpg generates __asyncpg_stmt_xx__. Auto-generated names mean the driver is doing server-side prepares without your code asking for it.

How to diagnose it

  1. Confirm the error is coming from PostgreSQL, not PgBouncer. The error string prepared statement ... does not exist is a PostgreSQL error (SQLSTATE 26000) relayed through PgBouncer. PgBouncer itself logs nothing about it. If PgBouncer’s log is clean and SHOW POOLS is green, that confirms the pattern.

  2. Correlate error bursts with concurrency. Pull timestamps of the errors from app logs and lay them next to connection counts (SHOW POOLS history, cl_active, transaction rate from SHOW STATS_AVERAGES). The signature: error rate scales with pool turnover, not with total query volume. Zero errors at 2 a.m., steady errors at peak.

  3. Identify who is preparing. Match the statement names against your stack’s driver conventions (check 4 above). If the names are auto-generated, your ORM or driver defaults are responsible: Hibernate, Django with persistent connections, JDBC with a nonzero prepare threshold, asyncpg with its statement cache, PDO with native prepares.

  4. Verify the PgBouncer version and whether tracking is enabled. SHOW VERSION, then check max_prepared_statements in SHOW CONFIG. On anything older than 1.21, there is no server-side workaround in PgBouncer at all. On 1.21+, check whether the feature is on (it defaults to 0, disabled).

  5. Reproduce deliberately. Run two concurrent clients through the same pool doing PREPARE in one transaction and EXECUTE in the next, with pool_size greater than 1. The error reproduces immediately. This gives you a regression test for whichever fix you choose.

Metrics and signals to monitor

No PgBouncer metric detects this failure directly. PgBouncer has zero error counters in any SHOW command; the failure lives in application logs. What you can monitor:

SignalWhy it mattersWarning sign
Application log rate of prepared statement ... does not existThe only direct signalAny occurrence in production
pool_mode in SHOW CONFIGConfiguration drift check after reloads and deploystransaction when the app expects session
cl_active / transaction ratePool turnover context: how aggressively connections are being reassignedError rate tracking turnover rate confirms diagnosis
Prepared statement counters (1.24+: client_parse_count, server_parse_count, bind_count in SHOW STATS)Confirms whether tracking is actually re-preparing on backendsclient_parse_count high but server_parse_count near zero means statements are not being replayed
avg_wait_time, cl_waiting, sv_active/pool_sizeGuardrail: verifies the failure is not a saturation problem masquerading as oneAll green, which is itself the confirming signal for this pattern

Column positions in SHOW STATS are version-dependent (1.24 added the prepared statement counters). Reference columns by name, not position.

Fixes

There are three real options, ordered by how much of your stack they touch.

Enable max_prepared_statements (PgBouncer 1.21+)

Set max_prepared_statements to a nonzero value (the release notes suggest something like 100 as a starting point) and RELOAD. PgBouncer then intercepts protocol-level Parse messages, renames statements to PGBOUNCER_<id>, and re-prepares them on demand on whichever backend the client currently holds. This is the only fix that keeps transaction pooling AND server-side prepares.

Know the limits before you commit:

  • Protocol-level only. PgBouncer tracks the extended query protocol’s Parse/Bind/Describe messages. It does not parse SQL text, so SQL-level PREPARE foo AS ... and DEALLOCATE are not tracked and never will be. If your code uses SQL-level prepares, this fix does nothing.
  • DEALLOCATE from drivers breaks it. Because statements are renamed server-side, a driver that sends DEALLOCATE pdo_stmt_00000001 (the original name) gets “does not exist” because the backend knows it as PGBOUNCER_1. PHP/PDO is the documented case: it is only compatible with this feature on PHP 8.4+ with libpq 17. Older PDO setups must use emulated prepares instead.
  • Version gate. You need 1.21+ for the feature and 1.24+ for the client_parse_count/server_parse_count/bind_count counters that let you verify it is working. If you are pinned to an older version, this option is unavailable.
  • Memory. PgBouncer holds the SQL text of every tracked statement per tracked backend. Negligible for typical queries, but worth knowing with a large limit and many backends.
  • One operator report noted a significant CPU increase after enabling it. Validate with your own load test before rolling out fleet-wide.

Disable server-side prepared statements client-side

The driver falls back to client-side (emulated) prepares: the SQL text is sent inline per execution, nothing is stored on the backend, and transaction pooling works fine. This is the lowest-risk fix and works on every PgBouncer version.

Per driver:

  • JDBC: prepareThreshold=0 on the connection URL.
  • PHP/PDO: PDO::ATTR_EMULATE_PREPARES => true.
  • asyncpg: statement_cache_size=0. The asyncpg docs call this out explicitly for PgBouncer users seeing intermittent __asyncpg_stmt_xx__ does not exist errors.
  • ORMs (Hibernate, Django): set the underlying driver’s prepare threshold to zero rather than fighting ORM-level options.

The cost is real but usually modest: you lose the parse/plan caching benefit of server-side prepares. For OLTP workloads behind a pooler, the difference is often small; measure before assuming it matters. The PgBouncer 1.21 release claimed large throughput gains from server-side prepared statements in synthetic benchmarks, so if your workload is parse-heavy, prefer the first fix.

Move the affected workload to session pooling

Set pool_mode = session for the specific database/user pair that needs session state, and leave the rest of the deployment in transaction mode. PgBouncer pools are per (database, user), so you can scope this precisely. Switch the affected pool to session mode as the immediate workaround, then audit the code for session-state dependencies.

The tradeoff: session mode holds a server connection for the client’s entire session. Your multiplexing ratio drops to 1:1 for that pool, and you must size pool_size for concurrent sessions rather than concurrent transactions. Watch sv_active and cl_waiting on that pool after the switch; it will need more capacity than it did in transaction mode.

What not to do

Do not try to fix this with server_reset_query. DISCARD ALL runs between client assignments and clears leftover session state, which is about hygiene in the other direction. It cannot preserve statements across reassignment, and older guidance about adding DEALLOCATE ALL there predates max_prepared_statements and only addresses cleanup, not availability.

Prevention

  • Audit before switching pool modes. Before moving any pool to transaction mode, grep the application and check driver defaults for prepared statements, temp tables, SET, advisory locks, and LISTEN/NOTIFY. This is the single highest-value step; the failure is designed-in otherwise.
  • Treat driver defaults as configuration. Most ORMs enable server-side prepares silently. Pin the prepare behavior explicitly (threshold, cache size, emulate flag) in every service’s database config, and document which mode each service expects.
  • Test with concurrency. A single-user smoke test cannot catch this class of bug. Any pool mode change or driver upgrade should be validated with at least as many concurrent clients as pool_size, forcing real reassignment.
  • Alert on the error string. Since PgBouncer exposes no counter, ship application logs and alert on any occurrence of prepared statement .* does not exist in production. Zero is the only acceptable rate.
  • Verify pool_mode after every RELOAD. Configuration drift on pool_mode is a realistic incident cause. Include it in post-deploy config verification (SHOW CONFIG).
  • Keep PgBouncer current. The prepared statement feature and its observability counters are recent (1.21 and 1.24 respectively), and recent releases carry security fixes. Track the release stream.

How Netdata helps

  • Pool state correlation in one view: cl_waiting, maxwait, sv_active, and sv_idle per pool, so you can confirm in seconds that the pooler is healthy and the problem is application-level, which is the defining diagnostic step for this failure.
  • Pool turnover signals: transaction and query rates per database from SHOW STATS, giving you the concurrency context to correlate error bursts in app logs against pool reassignment pressure.
  • Prepared statement counters on 1.24+: client_parse_count and server_parse_count collected and charted, so you can verify max_prepared_statements is actually replaying statements on backends after you enable it.
  • Config and version visibility: tracking pool_mode and version across your PgBouncer fleet catches drift and flags instances too old for the tracking feature.
  • Session-mode guardrails after a fix: if you scope a pool to session mode as the workaround, per-pool utilization and wait-time alerting catches the capacity regression that switch can cause.