Treat PostgreSQL planning as a costed search over legal execution paths, then make parameter sensitivity and prepared-plan reuse visible without confusing planner cost with wall-clock time.

Planner Search Space, Paths, Join Orders, Cost Parameters, and Generic vs Custom Plans

Understand PostgreSQL planning as a costed search over scan/join/order alternatives, then expose parameter sensitivity and prepared-plan reuse without mistaking cost for elapsed time.

Intermediate → Advanced180–240 minutesPlanner/executor evidence labCurrent patched PostgreSQL 18.xCore PostgreSQL only; JIT section conditional on server build/providerLocal table-owner privileges; selected pg_settings visibility is publicEXPLAIN ANALYZE DML is transaction-wrapped and rolled backNo paid or managed-service dependencyLast reviewed: August 2026

Learning outcomes

ServiceHub has a performance incident that looks contradictory. The same prepared query is extremely fast for a small tenant but slow for the largest tenant. An engineer opens EXPLAIN, sees a plan with “cost 42”, and reports that it should take roughly 42 milliseconds. Another engineer forces index scans globally. Both responses confuse the planner's model with execution reality.

PostgreSQL planning is a search problem. The planner considers legal ways—called paths—to obtain rows from base relations, combine relations, order data, aggregate it, and satisfy other query requirements. Each path receives cardinality and cost estimates. The planner chooses a complete plan with the lowest estimated cost according to its model, not according to a promise about elapsed time.

01

Distinguish relations, paths, plan nodes, selectivity, cardinality, startup cost, and total cost.

02

Explain why join order expands the search space and how parameterized paths enable efficient nested-loop plans.

03

Interpret seq_page_cost, random_page_cost, cpu_tuple_cost, effective_cache_size, and related parameters as model inputs rather than universal tuning targets.

04

Demonstrate custom and generic prepared plans, including PostgreSQL's automatic plan-cache heuristic and pg_prepared_statements counters.

05

Use plan_cache_mode and EXPLAIN (GENERIC_PLAN) diagnostically, then restore defaults instead of freezing a plan policy from one example.

Mental model

The planner does not execute every candidate and time it. It estimates cardinalities and costs from statistics plus configuration, prunes alternatives, and hands one plan tree to the executor. A poor plan is often a symptom of poor estimates or mismatched cost assumptions, not proof that a node type is intrinsically bad.

1. Build a skewed ServiceHub planning lab

The lab intentionally creates one very large tenant and many small tenants. Parameter sensitivity is much easier to understand when the data distribution actually gives different parameter values different optimal access strategies.

sql · setup skewed work orders
DROP TABLE IF EXISTS app.ch11_work_orders CASCADE;DROP TABLE IF EXISTS app.ch11_tenants CASCADE;CREATE TABLE app.ch11_tenants (    tenant_id integer PRIMARY KEY,    tenant_name text NOT NULL,    service_region text NOT NULL);INSERT INTO app.ch11_tenantsSELECT g,       format('Tenant %s', g),       CASE g % 4 WHEN 0 THEN 'north' WHEN 1 THEN 'south' WHEN 2 THEN 'east' ELSE 'west' ENDFROM generate_series(1, 40) AS g;CREATE TABLE app.ch11_work_orders (    work_order_id bigint PRIMARY KEY,    tenant_id integer NOT NULL REFERENCES app.ch11_tenants,    status text NOT NULL,    created_at timestamptz NOT NULL,    amount numeric(12,2) NOT NULL,    technician_id integer NOT NULL);INSERT INTO app.ch11_work_ordersSELECT g,       CASE WHEN g <= 180000 THEN 1 ELSE 2 + ((g - 180001) % 39) END,       CASE g % 5 WHEN 0 THEN 'queued' WHEN 1 THEN 'assigned' WHEN 2 THEN 'done' WHEN 3 THEN 'cancelled' ELSE 'waiting' END,       timestamptz '2026-01-01 00:00+00' + g * interval '15 seconds',       ((g % 50000) + 100)::numeric / 100,       1 + (g % 500)FROM generate_series(1, 240000) AS g;CREATE INDEX ch11_work_orders_tenant_created_idxON app.ch11_work_orders (tenant_id, created_at DESC);ANALYZE app.ch11_tenants;ANALYZE app.ch11_work_orders;

Tenant 1 owns most rows. A small tenant owns only a fraction of that volume. The same SQL text with different bind values can therefore justify different plans.

sql · confirm skew rather than assume it
SELECT tenant_id, count(*) AS rows_per_tenantFROM app.ch11_work_ordersGROUP BY tenant_idORDER BY rows_per_tenant DESC, tenant_idLIMIT 8;

2. From relation to path to plan

A relation in planner terminology can be a base table, a join relation, or another logical row source the planner reasons about. For a base table, candidate paths may include a sequential scan, an index scan, a bitmap path, or a parallel-aware path. A join relation might have nested-loop, hash-join, or merge-join paths. A parameterized path depends on values supplied by rows from another relation; this is especially important inside nested loops.

sql · observe a join that may use an outer value as an inner index condition
EXPLAIN (COSTS, SETTINGS)SELECT t.tenant_name, w.work_order_id, w.created_atFROM app.ch11_tenants AS tJOIN app.ch11_work_orders AS w  ON w.tenant_id = t.tenant_idWHERE t.service_region = 'east'  AND w.created_at >= timestamptz '2026-02-01 00:00+00'ORDER BY t.tenant_id, w.created_at DESCLIMIT 100;

If a nested loop is chosen, an inner Index Cond such as tenant_id = t.tenant_id demonstrates the executor receiving a parameter from the current outer row. If your server instead chooses a hash or merge join, that does not make the concept false; it means the local cost model preferred another legal path for this dataset and settings.

3. Cost is a dimensionless model, not milliseconds

PostgreSQL's cost values are arbitrary units. Conventionally seq_page_cost is 1.0 and other disk/CPU costs are expressed relative to it. Startup cost estimates work required before a node can emit its first row; total cost estimates work if the node runs to completion. The parent includes child costs. Network transmission and result formatting are not fully represented in those costs.

sql · inspect cost-model inputs
SELECT name, setting, unit, contextFROM pg_settingsWHERE name IN (  'seq_page_cost','random_page_cost','cpu_tuple_cost','cpu_index_tuple_cost',  'cpu_operator_cost','effective_cache_size','parallel_setup_cost','parallel_tuple_cost',  'join_collapse_limit','from_collapse_limit','geqo_threshold','plan_cache_mode')ORDER BY name;

effective_cache_size is not a cache allocation. It is an estimate the planner uses about cache availability. Likewise, reducing random_page_cost does not make storage faster; it changes the planner's belief about the relative cost of random page access.

Wrong approach

“The index plan is slower, so set random_page_cost = 1 everywhere” skips the diagnosis. First verify row estimates, cache/IO behavior, visibility, relation size, and the actual storage profile. Cost parameters are cluster/workload assumptions; use session-local experiments before persistent changes.

4. Join-order search and why query size matters

For inner joins the planner can often reorder relations because relational equivalence gives many legal join trees. The number of possible orders grows rapidly. PostgreSQL uses settings such as join_collapse_limit and eventually the Genetic Query Optimizer (GEQO) threshold to keep planning work bounded. This means planning time is itself a resource.

sql · measure planning choices without changing global configuration
BEGIN;SET LOCAL join_collapse_limit = 1;EXPLAIN (SETTINGS)SELECT count(*)FROM app.ch11_tenants tJOIN app.ch11_work_orders w ON w.tenant_id = t.tenant_idJOIN (SELECT DISTINCT technician_id FROM app.ch11_work_orders) x  ON x.technician_id = w.technician_idWHERE t.service_region = 'north';ROLLBACK;

Setting join_collapse_limit = 1 can preserve explicit join order for eligible joins, but it is a diagnostic and advanced planning control—not a general recommendation to hand-plan joins. Outer joins and other semantics also constrain reorderings regardless of this setting.

5. Prepared statements: custom versus generic plans

A custom plan is planned using the actual parameter values for one execution. A generic plan does not depend on those values and can be reused. PostgreSQL's default plan_cache_mode = auto initially uses custom plans for parameterized prepared statements, then compares the average estimated cost of the first five custom plans with a generic plan and may reuse the generic plan when replanning no longer appears worthwhile.

sql · prepare one parameter-sensitive query
DEALLOCATE ALL;PREPARE servicehub_tenant(integer) ASSELECT work_order_id, created_at, amountFROM app.ch11_work_ordersWHERE tenant_id = $1ORDER BY created_at DESCLIMIT 1000;
sql · force a custom plan for deterministic inspection
BEGIN;SET LOCAL plan_cache_mode = force_custom_plan;EXPLAIN (ANALYZE, BUFFERS, SETTINGS, TIMING OFF)EXECUTE servicehub_tenant(40);EXPLAIN (ANALYZE, BUFFERS, SETTINGS, TIMING OFF)EXECUTE servicehub_tenant(1);ROLLBACK;

In a custom plan, EXPLAIN EXECUTE generally shows the supplied literal in predicates. Tenant 40 may favor a narrow index-driven plan. Tenant 1 may still use the index because the ORDER BY ... LIMIT contract can make ordered retrieval attractive; do not prescribe the expected node name in advance.

sql · force a generic plan and compare the parameter symbol
BEGIN;SET LOCAL plan_cache_mode = force_generic_plan;EXPLAIN (ANALYZE, BUFFERS, SETTINGS, TIMING OFF)EXECUTE servicehub_tenant(40);EXPLAIN (ANALYZE, BUFFERS, SETTINGS, TIMING OFF)EXECUTE servicehub_tenant(1);ROLLBACK;

The generic form usually contains $1 rather than a value-specific literal. The important comparison is not “generic bad, custom good.” It is whether execution cost varies enough by parameter to justify repeated planning.

sql · inspect session-local prepared-plan counters
SELECT name, parameter_types, generic_plans, custom_plansFROM pg_prepared_statementsWHERE name = 'servicehub_tenant';

6. PostgreSQL 18 can show a generic plan without PREPARE

PostgreSQL 18's EXPLAIN (GENERIC_PLAN) accepts parameter placeholders and generates a generic plan without executing it. It cannot be combined with ANALYZE, because actual execution would require actual parameter values.

sql · generic plan with typed placeholder
EXPLAIN (GENERIC_PLAN, SETTINGS)SELECT work_order_id, created_atFROM app.ch11_work_ordersWHERE tenant_id = $1::integerORDER BY created_at DESCLIMIT 100;
Evidence boundary

A generic EXPLAIN is evidence about the chosen generic plan under current statistics/settings. It is not evidence about runtime for every parameter, and it does not prove that a driver will keep the statement prepared long enough for the server to adopt a generic plan.

7. Production judgment and cleanup

Investigate prepared-plan incidents by recording query shape, parameter distribution, row estimates, generic/custom counters, planning time, execution evidence, and driver pooling behavior. A transaction-pooling layer may change how long prepared state survives. DDL, statistics updates, and relevant environment changes can also trigger re-analysis/replanning. Do not solve parameter sensitivity by forcing one plan mode globally unless repeated production evidence supports that policy.

sql · cleanup
DEALLOCATE ALL;DROP TABLE IF EXISTS app.ch11_work_orders;DROP TABLE IF EXISTS app.ch11_tenants;

Check your understanding

  1. Why is PostgreSQL cost not an elapsed-time prediction?
  2. What is a parameterized path?
  3. Why can one prepared statement need different plans for different values?
  4. What does PostgreSQL auto plan caching do after the first five custom executions?
  5. Why should plan_cache_mode normally be restored after a diagnostic experiment?
Review the answers

Planner cost is a relative optimization model in arbitrary units. A parameterized path depends on values from another plan level, commonly an outer nested-loop row. Skew can make selectivity and therefore the cheapest path depend on a bind value. In auto mode PostgreSQL compares a generic-plan estimate with the average of the first five custom-plan estimates and may choose reuse. Forcing a mode globally can trade one parameter problem for another and should be evidence-driven.

Authoritative references

Planner and executor behavior is version-sensitive and workload-sensitive. Verify the exact PostgreSQL major, statistics state, server settings, indexes, and data distribution before generalizing any plan shown in this lesson.

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.