EXPLAIN GENERIC_PLAN for Parameterized SQL #
pg_stat_statements shows the statement eating your CPU as SELECT … FROM shipments WHERE carrier_id = $1 AND status = $2 ORDER BY created_at DESC LIMIT $3. You want its plan, but it has no values, and picking arbitrary ones gives you a custom plan that may be nothing like what the application actually runs after its prepared statements switch to a generic plan.
PostgreSQL 16 added EXPLAIN (GENERIC_PLAN) for exactly this. The wider set of options is in EXPLAIN options and output formats; the lifecycle that decides when a generic plan is used is in prepared statement plan pinning.
The Planner Condition #
With GENERIC_PLAN, each $n is treated as a parameter of unknown value and inferred type, and the planner builds exactly the plan a cached generic plan would use:
- Equality selectivity comes from the column’s average: roughly
(1 − null_frac) / n_distinct, not from the MCV list, because no value is known. - Range selectivity on an unknown bound uses a fixed default fraction.
LIMIT $3is costed with a default assumption about the row fraction fetched, since the limit value is unknown.- Partition pruning cannot happen at plan time; the plan lists all partitions and relies on execution-time pruning, reported as
Subplans Removedonly when the statement actually runs — the distinction covered in runtime vs plan-time partition pruning.
The option cannot be combined with ANALYZE, because execution needs values.
Annotated EXPLAIN Evidence #
EXPLAIN (GENERIC_PLAN)
SELECT id, tracking_no FROM shipments
WHERE carrier_id = $1 AND status = $2
ORDER BY created_at DESC
LIMIT $3;
Limit (cost=0.56..412.30 rows=… width=24)
-> Index Scan Backward using shipments_created_at_idx on shipments
(cost=0.56..82314.10 rows=2010 width=24)
Filter: ((carrier_id = $1) AND (status = $2)) -- $n placeholders: generic plan
-- rows=2010 is the averaged estimate for any carrier and status
-- the planner walks created_at backwards, filtering, expecting to hit LIMIT rows quickly
The custom plan for a small carrier:
EXPLAIN SELECT id, tracking_no FROM shipments
WHERE carrier_id = 912 AND status = 'in_transit'
ORDER BY created_at DESC LIMIT 50;
Limit (cost=184.20..184.32 rows=50 width=24)
-> Sort (cost=184.20..184.60 rows=160 width=24)
Sort Key: created_at DESC
-> Index Scan using shipments_carrier_status_idx on shipments
(cost=0.56..179.10 rows=160 width=24)
Index Cond: ((carrier_id = 912) AND (status = 'in_transit'::text))
-- literal values: a 160-row index lookup, then a tiny sort
For carrier 912 the generic plan walks the whole created_at index backwards looking for 50 matches that are rare and old — the classic ORDER BY … LIMIT trap, discussed in why index scans sometimes underperform. For a large carrier it is excellent. Which one the application experiences depends on whether the generic plan is in use.
Step-by-Step Resolution #
-
Take the statement text verbatim from
pg_stat_statements.queryor anauto_explainlog line. Keep the$nplaceholders. -
Run
EXPLAIN (GENERIC_PLAN), adding casts where the type cannot be inferred:EXPLAIN (GENERIC_PLAN, SETTINGS) SELECT … WHERE created_at >= $1::timestamptz AND tenant_id = $2::bigint; -
Read the plan for placeholder-driven choices —
Filter:lines with$n, averagedrows=, and every partition listed. -
Compare custom plans for a rare and a common value of each skewed parameter. Use
pg_stats.most_common_valsto pick them. -
Time the generic plan with real values before deciding anything:
PREPARE q (int, text, int) AS SELECT id, tracking_no FROM shipments WHERE carrier_id = $1 AND status = $2 ORDER BY created_at DESC LIMIT $3; SET plan_cache_mode = force_generic_plan; EXPLAIN (ANALYZE, BUFFERS) EXECUTE q (912, 'in_transit', 50); SET plan_cache_mode = force_custom_plan; EXPLAIN (ANALYZE, BUFFERS) EXECUTE q (912, 'in_transit', 50); -
Choose the remedy. If the generic plan is bad for real traffic, either force custom plans with plan_cache_mode for that workload or give the generic plan a good option — here an index on
(carrier_id, status, created_at DESC)makes both plans the same fast index scan.
Before and After #
-- BEFORE: generic plan, carrier 912
Index Scan Backward using shipments_created_at_idx Rows Removed by Filter: 18204410 Execution Time: 3840.6 ms
-- AFTER: CREATE INDEX ON shipments (carrier_id, status, created_at DESC)
Index Scan using shipments_carrier_status_created_idx Index Cond: (carrier_id = $1 AND status = $2) Execution Time: 0.8 ms
Why the index fixed both plans at once #
The generic plan was not wrong because it lacked values; it was wrong because the only indexes available forced a choice between two strategies whose costs depended heavily on the values. Walking created_at backwards is excellent when matches are dense and terrible when they are sparse. Looking up by carrier and sorting is excellent when matches are few and expensive when they are many. A plan without values has to bet on one.
An index on (carrier_id, status, created_at DESC) removes the bet. Equality on the first two columns positions the scan at exactly the right carrier and status, and the third column already provides the requested order, so the scan returns rows in ORDER BY order and stops after LIMIT rows whatever the carrier’s size. When the best plan for every parameter value is the same plan, the generic plan and every custom plan converge, and the five-execution switch stops mattering. That is the most robust fix for any statement where GENERIC_PLAN output diverges from custom plans: give the planner an access path whose cost does not depend on selectivity.
Using GENERIC_PLAN as a regression check #
Because GENERIC_PLAN needs no data values and no execution, it is cheap enough to run in continuous integration. Store the statement texts your application prepares — pg_stat_statements on a staging database is a convenient source — and after each migration run EXPLAIN (GENERIC_PLAN, FORMAT JSON) for each. Compare node types and index names with the previous run. A generic plan that switches from an index scan to a sequential scan after a migration is worth a look before deployment, even though the custom plans you test by hand may look unchanged. Parsing that output is straightforward with the approach in parsing EXPLAIN JSON output.
Two limits apply. Statements that depend on session state — temporary tables, search_path, row-level security settings — need that state recreated before planning. And the check sees plans, not timings: a plan that is unchanged in shape can still slow down as data grows.
Common Pitfalls #
Planning a normalized statement with made-up literals. Any literal produces a custom plan for that value. Diagnostic signal: a fast plan in your terminal and a slow statement in pg_stat_statements. Fix: plan the $n form with GENERIC_PLAN.
Placeholders the planner cannot type. WHERE created_at > $1 against an expression may fail type inference. Diagnostic signal: “could not determine data type of parameter $1”. Fix: add explicit casts.
Assuming the application uses the generic plan. It depends on plan_cache_mode, the five-execution rule, and whether the driver prepares statements at all. Diagnostic signal: generic and logged plans differ. Fix: check pg_prepared_statements.generic_plans on an application connection or capture with auto_explain.
Replacing $3 in LIMIT with a constant for testing. The generic plan’s cost for LIMIT $3 uses a default assumption, which can differ from LIMIT 50. Diagnostic signal: different plan shapes for the placeholder and literal limits. Fix: test both, and prefer the placeholder form to match production.
Frequently Asked Questions #
Which PostgreSQL version supports EXPLAIN GENERIC_PLAN?
PostgreSQL 16 and later. On older versions, PREPARE the statement, set plan_cache_mode = force_generic_plan, and run EXPLAIN EXECUTE with any values.
Why can’t I use GENERIC_PLAN with ANALYZE?
ANALYZE executes the statement, which needs values. Prepare the statement, force the generic plan, and run EXPLAIN ANALYZE EXECUTE with representative values instead.
What if the planner cannot infer a parameter’s type?
Planning fails. Add an explicit cast at the placeholder, such as $1::bigint.
Related #
- EXPLAIN Options and Output Formats — parent guide: all EXPLAIN options
- Generic vs Custom Plan Selection — the cost comparison that decides which plan runs
- EXPLAIN SETTINGS, WAL and SERIALIZE Options — sibling: the other options that close plan-versus-reality gaps