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
EXECUTElands 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
| Cause | What it looks like | First thing to check |
|---|---|---|
| ORM using server-side prepared statements | Errors 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 audit | Errors start the day pool_mode changed; nothing else changed | SHOW CONFIG for pool_mode; config change history |
SQL-level PREPARE / DEALLOCATE in application code | Errors persist even with max_prepared_statements set | Grep 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 enabled | Driver name and version (PHP/PDO is the known case) |
| Unnamed prepared statement reuse | unnamed prepared statement does not exist variant of the error | Driver-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
Confirm the error is coming from PostgreSQL, not PgBouncer. The error string
prepared statement ... does not existis a PostgreSQL error (SQLSTATE 26000) relayed through PgBouncer. PgBouncer itself logs nothing about it. If PgBouncer’s log is clean andSHOW POOLSis green, that confirms the pattern.Correlate error bursts with concurrency. Pull timestamps of the errors from app logs and lay them next to connection counts (
SHOW POOLShistory,cl_active, transaction rate fromSHOW 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.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.
Verify the PgBouncer version and whether tracking is enabled.
SHOW VERSION, then checkmax_prepared_statementsinSHOW 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).Reproduce deliberately. Run two concurrent clients through the same pool doing
PREPAREin one transaction andEXECUTEin the next, withpool_sizegreater 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:
| Signal | Why it matters | Warning sign |
|---|---|---|
Application log rate of prepared statement ... does not exist | The only direct signal | Any occurrence in production |
pool_mode in SHOW CONFIG | Configuration drift check after reloads and deploys | transaction when the app expects session |
cl_active / transaction rate | Pool turnover context: how aggressively connections are being reassigned | Error 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 backends | client_parse_count high but server_parse_count near zero means statements are not being replayed |
avg_wait_time, cl_waiting, sv_active/pool_size | Guardrail: verifies the failure is not a saturation problem masquerading as one | All 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 ...andDEALLOCATEare not tracked and never will be. If your code uses SQL-level prepares, this fix does nothing. DEALLOCATEfrom drivers breaks it. Because statements are renamed server-side, a driver that sendsDEALLOCATE pdo_stmt_00000001(the original name) gets “does not exist” because the backend knows it asPGBOUNCER_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_countcounters 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=0on 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 existerrors. - 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, andLISTEN/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 existin production. Zero is the only acceptable rate. - Verify pool_mode after every RELOAD. Configuration drift on
pool_modeis 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, andsv_idleper 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_countandserver_parse_countcollected and charted, so you can verifymax_prepared_statementsis actually replaying statements on backends after you enable it. - Config and version visibility: tracking
pool_modeand 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.
Related guides
- PgBouncer avg_wait_time high: the latency the pool itself is injecting
- PgBouncer capacity planning: runway for pools, clients, and PostgreSQL slots
- How PgBouncer actually works in production: a mental model for operators
- PgBouncer maxwait high: the oldest client waiter and how close it is to timing out
- PgBouncer monitoring checklist: the signals every connection pooler needs
- PgBouncer monitoring maturity model: from survival to expert
- PgBouncer pool exhaustion: clients queue, wait times climb, and the retry cascade
- PgBouncer pool utilization high: sv_active approaching pool_size before clients queue
- PgBouncer query_wait_timeout: clients disconnected after waiting too long for a connection
- PgBouncer reserve pool activation: overflow capacity that hides an undersized pool
- PgBouncer pool_size sizing: matching pool capacity to transaction time and throughput
- PgBouncer sv_idle at zero: no headroom and one slow query from a cascade






