cached plan must not change result type
Two server-side errors hit the same nerve in a JDBC application that relies on server-prepared statements:
ERROR: cached plan must not change result type
ERROR: prepared statement "S_2" does not exist
Both arise when the server-side prepared statement stored against
a name (e.g., S_2) is no longer valid against the current catalogue
or session state. The first is SQLState = 0A000
(FEATURE_NOT_SUPPORTED); the second is SQLState = 26000
(INVALID_SQL_STATEMENT_NAME). The driver recognises both as
“reparse-and-retry” candidates; see
QueryExecutorBase.willHealViaReparse
for the exact decision.
What server-prepared statements are
A PreparedStatement that reaches
prepareThreshold
executions (default 5) gets a real named PARSE on the backend;
subsequent executions only BIND and EXECUTE against that name.
The server holds onto the parsed plan under a generated name (S_1,
S_2, …), and the driver tracks the same name. The plan now has a
lifecycle: it can be invalidated by catalogue changes, backend-session
state changes, or the backend connection disappearing.
When the cached plan no longer matches
In rough order of frequency:
- Schema migration mid-flight.
ALTER TABLE … ADD COLUMN,ALTER TABLE … ALTER COLUMN TYPE, dropping a view and recreating it, renaming a column: anything that changes the result columns of aSELECTthe application has already prepared can make an old plan fail withcached plan must not change result type. In this branch the defaultflushCacheOnDdlsetting invalidates the driver’s prepared-statement cache when the same session observes a top-levelCREATE,DROP, orALTERcommand tag, so the next execute re-prepares instead of using the stale plan. SEARCH_PATHchange. The first execution resolved tableuserstomyapp.users; a laterSET search_path = …makes the same query resolve to a different relation. The driver detectsSET … search_path …and bumps an internaldeallocateEpochso fresh PARSE messages are emitted, but only for the connection that issued the SET. A SET applied via a server-side trigger or by another session sharing the same prepared statement (rare) does not trigger the invalidation.DEALLOCATE ALLorDISCARD ALLon the server. Same mechanism: the driver watches the command tag, bumps the epoch, re-PARSEs on next execute. Useful when the application itself doesn’t issue these, but a pool’sreset querydoes.- PgBouncer in transaction (or statement) pooling mode. A connection returned to the pool can be re-checked-out by a different session backed by a different server connection. The server-prepared statements live on the backend connection, not on the pooled client connection, so the next execute lands on a server that has never seen the prepared statement.
- Server restart / failover. The new backend has no prepared statements. Same surface error as PgBouncer.
Fixes
These address the cause: either the prepared statement is not being preserved across the pooling boundary, or the application is mutating the catalogue without telling its pool.
flushCacheOnDdl (default true)
For DDL issued through the same pgJDBC connection, the default fix is
already enabled. When the backend returns a CREATE, DROP, or
ALTER command tag, pgJDBC invalidates its prepared-statement cache
so subsequent executions are re-parsed against the current catalogue.
Set flushCacheOnDdl=false only when you intentionally need the
legacy behaviour, where cached plan must not change result type can
surface and transparent recovery depends on autosave.
Make PgBouncer keep server-prepared statements
PgBouncer 1.21 (December 2023) added
max_prepared_statements
.
With a non-zero value, PgBouncer tracks prepared statements per
client and replays the PARSE against whichever server connection
hands out next. If your stack runs an older PgBouncer in
transaction-pool mode, upgrade first; otherwise the prepared
statement cache is effectively a per-checkout cache and the error is
guaranteed to fire under load.
Schema-migration hygiene
When DDL can happen outside the pgJDBC connection that owns the prepared statements, close and re-open the affected connections after the migration finishes. The pool then rebuilds connections that never saw the pre-migration catalogue, so no cached plan exists to invalidate.
Workarounds
Sometimes the root cause is out of reach: a third-party application issues the migration, the PgBouncer upgrade is pending, the deployment cannot evict pool connections. The driver-side knobs below let the driver tolerate the invalidation instead of resolving it. They are not free: each pays a cost beyond the statements that would have failed.
autosave=conservative
The most surgical of the three. With
autosave=conservative
,
the driver places an automatic savepoint before statements that can
hit this class of failure while a transaction is active. When the
statement fails with one of the recoverable errors (cached plan must not change result type, prepared statement "S_X" does not exist),
the driver rolls back to the savepoint so the statement can be
reparsed and retried transparently. From the application’s
perspective the statement simply succeeds.
willHealViaReparse is the gate: it checks for either
SQLState 26000 or SQLState 0A000 paired with the
RevalidateCachedQuery / RevalidateCachedPlan server routine.
Random 0A000 errors that are not cached-plan invalidations are
not retried. Savepoint overhead is paid, but unrelated failures
still surface to the application.
The CONSERVATIVE mode sends extra savepoint traffic for statements
that might fail this way in an active transaction. autosave=always
uses automatic savepoints more broadly and rolls back on any error;
autosave=never (the default) gives no savepoint-based recovery.
prepareThreshold=0
prepareThreshold=0
disables server-side preparation altogether. Every statement is
parsed afresh; there is nothing to invalidate. Useful when a
deployment migrates the schema often, doesn’t see enough repeats of
each query to benefit from caching anyway, or is constrained by
PgBouncer’s pooling mode (see above).
The cost is real: a query that would have been served from cache pays full parse and plan cost on every execute. Benchmark before flipping.
preferQueryMode=simple
The most blunt option:
preferQueryMode=simple
uses the simple PostgreSQL protocol with no PARSE / BIND /
EXECUTE cycle at all; parameters are interpolated on the client.
Server-prepared statements aren’t possible, so neither is this
error. The trade-offs are larger than prepareThreshold=0 (no
binary transfer, weaker parameter typing); pick simple mode only if
the deployment also blocks the extended protocol for other reasons.
Related connection properties
| Name | Type | Default | Versions | Description |
|---|---|---|---|---|
flushCacheOnDdl | boolean | true |
| Invalidate the prepared-statement cache when a CREATE/DROP/ALTER CommandComplete is observed (default true). Disable for legacy behavior that surfaces ‘cached plan must not change result type’. |
preferQueryMode | enum | extended |
| Specifies which mode is used to execute queries to database: simple means (‘Q’ execute, no parse, no bind, text mode only), extended means always use bind/execute messages, extendedForPrepared means extended for prepared statements only, extendedCacheEverything means use extended protocol and try cache every statement (including Statement.execute(String sql)) in a query cache. |
preparedStatementCacheQueries | int | 256 |
| Specifies the maximum number of entries in per-connection cache of prepared statements. A value of {@code 0} disables the cache. |
preparedStatementCacheSizeMiB | int | 5 |
| Specifies the maximum size (in megabytes) of a per-connection prepared statement cache. A value of {@code 0} disables the cache. |
prepareThreshold | int | 5 |
| Statement prepare threshold. A value of {@code -1} stands for forceBinary |
Related
- Server-prepared statements
:
how the
prepareThreshold,preparedStatementCacheQueries,preparedStatementCacheSizeMiB, andbinaryTransferproperties interact in a working configuration. - DataSource and JNDI : where PgBouncer and pool-eviction strategies fit in.
- Connection properties reference
:
autosave,prepareThreshold,preferQueryMode,cleanupSavepoints.