PostgreSQL · beginner
PostgreSQL CTE plan regression: detect when materialization changed the path
CTE plan regression in PostgreSQL occurs when the planner's treatment of a common table expression boundary changes—most often flipping between inlining and materialization—so a query that previously reused a result now re-evaluates it (or vice versa). The failure is plan-shaped: row estimates, node order, and buffer counts diverge from a known-good baseline even when the SQL text is unchanged. The goal is to detect the boundary, compare plans across versions, and choose the least-disruptive remedy.
The symptoms
- •EXPLAIN (ANALYZE, BUFFERS) shows a new "CTE Scan" or "Subquery Scan" node where the prior plan inlined the CTE directly into the outer query.
- •Execution time and shared/local buffer counts grow sharply while row counts stay similar, indicating the CTE is being re-evaluated per outer row rather than computed once.
- •Row estimates on the CTE-derived node deviate from actual rows by orders of magnitude, suggesting the planner chose materialization with stale statistics on the boundary.
- •Plan flips after a PostgreSQL minor upgrade, extension version change, or ANALYZE that alters pg_statistic for tables referenced inside the CTE.
- •The same query text produces different plans on a replica versus the primary, often correlated with read-only settings or differing work_mem affecting inlining thresholds.
- •Application latency increases on queries that touch a previously fast path, with no schema or data change in the underlying tables.
Likely causes
- •Planner switch from CTE inlining to materialization across PostgreSQL versions, where the optimizer becomes more conservative about folding a non-simple CTE into the outer query.
- •Self-referential or multi-referenced CTE that the planner must materialize, but where the cost estimate of that materialization is wrong because of skewed statistics on the inner relation.
- •Use of data-modifying CTEs (INSERT/UPDATE/DELETE ... RETURNING) or WITH ... [NOT] MATERIALIZED hints that force evaluation mode and are inherited by later versions that interpret them differently.
- •Changes to cte_inline_threshold, work_mem, or plan_cache_mode that shift the cost balance between inlining and materialization on a stable query.
- •Stats drift: stale pg_statistic after bulk loads, partition pruning changes, or new indexes that alter the planner's chosen path inside the CTE subtree.
- •Side effects of plan regression on a dependency: a view or function that wraps a CTE inherits a now-materialized node, and consumers further down the call chain feel the latency.
First ten minutes
- 01Capture the exact SQL text and bind values from the failing path; pin the database server version with `SHOW server_version;` and note the planner settings `SHOW cte_inline_threshold;`, `SHOW work_mem;`, `SHOW plan_cache_mode;`.
- 02Run EXPLAIN (ANALYZE, BUFFERS, VERBOSE) on the failing query in a session with the same role and search_path as production, and save the full plan text to a versioned file.
- 03Run EXPLAIN (ANALYZE, BUFFERS) on the same query text against a known-good reference (an older dump, a read replica, or a prior plan captured in auto_explain) and diff the node tree.
- 04Check pg_stat_user_tables and pg_stats for the relations referenced inside the CTE; record last_analyze and most_common_vals frequency rows that influence the boundary cost.
- 05Read pg_stat_statements for the queryid of the affected statement; compare mean_exec_time, calls, and shared_blks_hit/read across the regression window.
- 06Confirm whether the CTE is referenced more than once in the outer query, uses a data-modifying statement, or contains volatile functions—each of which forces materialization.
Evidence to collect
- •EXPLAIN (ANALYZE, BUFFERS, VERBOSE) output before and after the regression, stored as diffable text with planner settings annotated.
- •pg_stat_statements snapshot showing the queryid, mean_exec_time, calls, rows, and buffer counts across the regression window.
- •pg_stat_user_tables.n_mod_since_analyze and pg_stats correlation entries for the relations inside the CTE boundary.
- •Server configuration snapshot: server_version_num, cte_inline_threshold, work_mem, plan_cache_mode, jit settings.
- •auto_explain log lines for the failing query, if enabled, with nested statement timing and buffer usage per node.
- •List of CTE references in the outer query (single vs. multiple), presence of data-modifying statements, and use of MATERIALIZED/NOT MATERIALIZED hints.
Where to look
- •PostgreSQL planner boundary between the outer query and the CTE subtree, visible in EXPLAIN VERBOSE as a "CTE Scan" node versus inlined child nodes.
- •pg_stat_statements view for the queryid, where mean_exec_time and shared buffer deltas expose the materialization cost.
- •pg_stats and pg_class for the inner relations: staleness, n_distinct, and most_common_freqs drive the cost estimate at the boundary.
- •postgresql.conf and the active configuration source (ALTER SYSTEM, ALTER DATABASE, ALTER ROLE) for cte_inline_threshold and plan_cache_mode.
- •Application log correlation: trace the failing query's bind values back to a code path that may have changed, even when SQL text is identical.
- •Migration and deployment logs for the regression window to correlate plan flips with version upgrades, extension changes, or bulk loads.
Diagnostic steps
- 01Diff the two EXPLAIN (ANALYZE, BUFFERS) outputs and identify the first node where the plan shape diverges; mark whether the boundary is now a CTE Scan or remains inlined.
- 02Compute rows x width and actual time deltas at the boundary node; if actual time is much higher than estimated and loops > 1, suspect per-outer-row re-evaluation rather than one-shot materialization.
- 03Count CTE references in the outer query: a single reference with a simple SELECT is a candidate for inlining; multiple references or data-modifying CTEs force materialization and rule out inlining fixes.
- 04Recompute statistics with ANALYZE on the inner relations and re-run EXPLAIN; if the plan returns to the inlined form, attribute the regression to stats drift, not version semantics.
- 05Toggle the MATERIALIZED / NOT MATERIALIZED hint in a test session and re-EXPLAIN; the node tree should change in the opposite direction, confirming the boundary is the optimizer's choice.
- 06Cross-check the official monitoring statistics documentation to confirm the meaning of pg_stat_statements columns and the buffer accounting model before drawing conclusions.
- 07Reproduce on a restored backup or staging clone with production-like data volume; small datasets can mask the cost difference and lead to a false negative on the boundary.
Common mistakes
- •Assuming identical SQL text guarantees an identical plan; planner settings, statistics, and version semantics all sit between the text and the node tree.
- •Comparing only execution time and ignoring buffer counts, which is where materialization costs (extra reads, hashing) show up before wall-clock time diverges.
- •Forcing MATERIALIZED on a single-reference, side-effect-free CTE and accepting the per-outer-row cost as unavoidable, when inlining would have avoided the materialization altogether.
- •Running ANALYZE and declaring victory without re-checking EXPLAIN, because stats drift is one cause among several and the plan may still be wrong.
- •Trusting a plan captured on a small dev dataset and shipping the fix to production, where the boundary cost scales with row count and the regression returns.
Safe fixes
- •Rewrite the query so the CTE is referenced only once and contains no side effects, allowing the planner to inline it; verify with EXPLAIN VERBOSE that the CTE Scan node disappears.
- •Apply NOT MATERIALIZED explicitly in a test session when the CTE must remain a named boundary but inlining is desired; confirm the plan shape before promoting.
- •Run ANALYZE on the inner relations when pg_stat_user_tables.n_mod_since_analyze is high, then re-EXPLAIN to test whether correct statistics alone restore the plan.
- •Tune cte_inline_threshold upward in a test session when the planner refuses to inline a borderline CTE; re-EXPLAIN to confirm inlining, then consider the change in a controlled rollout.
- •Restructure multi-reference CTEs into temporary tables or derived tables when the cost of materialization exceeds the cost of recomputation for a single reference path.
- •Add an index or partial index that matches the CTE's filter, lowering the inner cost estimate so the planner prefers inlining at the boundary.
Prove the fix
- 01EXPLAIN (ANALYZE, BUFFERS, VERBOSE) shows the CTE boundary either inlined or explicitly NOT MATERIALIZED, and the estimated vs. actual row counts at that node agree within one order of magnitude.
- 02pg_stat_statements for the same queryid reports mean_exec_time returning to the pre-regression baseline over a statistically meaningful number of calls.
- 03Shared and local buffer counts per execution drop to within tolerance of the known-good plan, confirming the materialization cost is removed or bounded.
- 04Running EXPLAIN across at least three sessions with cleared caches and varied bind values produces the same plan shape, ruling out plan_cache_mode or plan instability.
- 05A canary run on a staging clone with production-scale data shows the plan shape and timing match the pre-regression baseline before the change is promoted.
- 06Auto_explain logs, if enabled, show the same node ordering and per-node timing as the reference plan for the regression window's representative traffic.
Prevention and next steps
- •Capture EXPLAIN (ANALYZE, BUFFERS) baselines for known-critical queries in version control, and diff them whenever PostgreSQL is upgraded or planner settings change.
- •Monitor pg_stat_statements for mean_exec_time drift on tagged queries and alert on plan-cache miss or buffer-count spikes that precede wall-clock regressions.
- •Schedule ANALYZE after bulk loads or partition operations, and track n_mod_since_analyze to bound the window in which stats drift can flip a CTE boundary.
- •Document the rationale for MATERIALIZED / NOT MATERIALIZED hints in code review so future changes do not silently flip planner behavior across versions.
- •Test plan stability on production-scale datasets in staging, not on small dev clones, so the cost of inlining versus materialization is evaluated under realistic row counts.
Safe commands and checks
SHOW server_version; SHOW server_version_num;
SHOW cte_inline_threshold; SHOW plan_cache_mode; SHOW work_mem; SHOW jit;
EXPLAIN (ANALYZE, BUFFERS, VERBOSE) <query_text>;
SELECT queryid, calls, mean_exec_time, rows, shared_blks_hit, shared_blks_read, temp_blks_read FROM pg_stat_statements WHERE query ILIKE '%<cte_marker>%' ORDER BY mean_exec_time DESC LIMIT 20;
SELECT relname, n_live_tup, n_mod_since_analyze, last_analyze, last_autoanalyze FROM pg_stat_user_tables WHERE relname IN (<inner_relations>);
SELECT attname, n_distinct, most_common_vals, most_common_freqs FROM pg_stats WHERE tablename = '<inner_relation>' AND attname IN (<filter_columns>);
SELECT pg_get_viewdef('<view_oid>'); -- to inspect CTEs nested inside views or functions
ANALYZE VERBOSE <inner_relation>; -- scoped to specific relations referenced inside the CTE