PostgreSQL Deep Dive

PostgreSQL Deep Dive: Generic vs Custom Plans for Prepared Statements — Why PREPARE Can Make Queries Slower

You moved your queries to prepared statements to avoid repeated parsing. Smart move. But now a query that used to run in 2ms takes 200ms — and only sometimes. You check the plan and it’s doing a sequential scan when it should be using an index.

The problem isn’t the index. It’s that your prepared statement is using a generic plan — one that doesn’t know about the actual parameter values you’re passing. Today we’re going deep into how PostgreSQL decides between generic and custom plans for prepared statements, why it gets it wrong, and what to do about it.

Prepared Statements: A Quick Refresher

When you run PREPARE, PostgreSQL does three things:

  1. Parse the SQL text into an abstract syntax tree
  2. Analyze it — resolve table/column references, check types
  3. Rewrite it — apply rules, expand views

What it does not do is plan the query. That happens at EXECUTE time, and this is where the generic vs custom decision comes in.

-- Create a prepared statement
PREPARE get_orders(date) AS
  SELECT * FROM orders WHERE created_at > $1;

-- Execute it (this is where planning happens)
EXECUTE get_orders('2026-04-01');

The separation is intentional: parsing and analysis are expensive but only need to happen once. Planning is cheaper but depends on the parameter values — and that tension is what today’s post is about.

Generic Plans vs Custom Plans

Custom Plan

A custom plan is generated fresh for each EXECUTE, using the actual parameter values. The planner knows that $1 = '2026-04-01', checks the statistics, and picks the best plan for that specific value.

-- Custom plan: parameter value is known
Index Scan using idx_orders_created_at on orders
  Index Cond: (created_at > '2026-04-01'::date)

Pros: Optimal plan for the specific parameter values Cons: Planning overhead on every execution

Generic Plan

A generic plan is created once and reused across all executions. The planner doesn’t know the actual parameter values — it works from generic statistics about the column.

-- Generic plan: parameter is a placeholder ($1)
Seq Scan on orders
  Filter: (created_at > $1)

Pros: No planning overhead after the first execution Cons: The plan may be suboptimal for specific parameter values

The Key Insight

For a query like SELECT * FROM orders WHERE status = $1:

  • When $1 = 'pending' (1% of rows) → index scan is ideal
  • When $1 = 'active' (50% of rows) → sequential scan is ideal

A custom plan would pick the right strategy each time. A generic plan picks one strategy for all values — and if it picks “seq scan” based on average selectivity, the ‘pending’ queries suffer.

How PostgreSQL Chooses: The Five-Execution Heuristic

In plan_cache_mode = auto (the default), PostgreSQL uses this algorithm:

  1. First 5 executions: Always use custom plans. Track the estimated cost of each.
  2. After 5th execution: Generate a generic plan. Compare its estimated cost to the average estimated cost of the 5 custom plans.
  3. Decision: If the generic plan’s cost is not significantly higher than the average custom-plan cost, use the generic plan from now on. Otherwise, keep using custom plans.
Execution 1:  Custom plan  (cost: 25.00)
Execution 2:  Custom plan  (cost: 25.00)
Execution 3:  Custom plan  (cost: 180.00)  ← different parameter value
Execution 4:  Custom plan  (cost: 25.00)
Execution 5:  Custom plan  (cost: 25.00)
Average custom-plan cost: 56.00

Execution 6:  Generic plan (cost: 50.00)  ← 50 < 56, so generic plan wins
Execution 7+: Use generic plan forever

Why This Can Go Wrong

The heuristic compares estimated costs, not actual execution times. If the planner’s row estimates are inaccurate (which happens — stale statistics, correlated columns, skewed distributions), the comparison is based on bad numbers.

Scenario: You have a orders table with 10M rows. The status column has:

  • completed: 9.9M rows (99%)
  • pending: 50K rows (0.5%)
  • error: 500 rows (0.005%)
PREPARE get_by_status(text) AS
  SELECT * FROM orders WHERE status = $1;

If the first 5 executions all pass $1 = 'completed', the custom plans all choose Seq Scan (correct). The generic plan also chooses Seq Scan (because the average selectivity is high). Now when you pass $1 = 'error', you get a Seq Scan over 10M rows when an Index Scan would have been 500x faster.

The heuristic didn’t fail — it picked a plan that was “not much worse than average.” But “average” was dominated by a common parameter value, and the rare value gets a terrible plan.

Detecting the Problem

1. Check EXPLAIN Output

PREPARE get_by_status(text) AS
  SELECT * FROM orders WHERE status = $1;

-- Run it 6 times to trigger generic plan evaluation
EXECUTE get_by_status('completed');
EXECUTE get_by_status('completed');
EXECUTE get_by_status('completed');
EXECUTE get_by_status('completed');
EXECUTE get_by_status('completed');

-- Now check the plan
EXPLAIN (ANALYZE) EXECUTE get_by_status('error');

If you see $1 in the plan instead of the actual value, it’s a generic plan:

-- Generic plan (bad for 'error')
Seq Scan on orders  (cost=0.00..180000.00 rows=100000 width=100)
  Filter: (status = $1)

A custom plan would show the literal value:

-- Custom plan (good for 'error')
Index Scan using idx_orders_status on orders  (cost=0.29..8.45 rows=5 width=100)
  Index Cond: (status = 'error')

2. Check pg_prepared_statements

SELECT name, statement, parameter_types, generic_plans, custom_plans
FROM pg_prepared_statements;

The generic_plans and custom_plans columns (PostgreSQL 14+) tell you how many times each type was used.

3. Compare Generic vs Custom Directly

-- Force custom plan and measure
SET LOCAL plan_cache_mode = force_custom_plan;
EXPLAIN (ANALYZE, BUFFERS) EXECUTE get_by_status('error');

-- Force generic plan and measure
SET LOCAL plan_cache_mode = force_generic_plan;
EXPLAIN (ANALYZE, BUFFERS) EXECUTE get_by_status('error');

If the custom plan is dramatically faster, you have a generic-plan problem.

The Fix: plan_cache_mode

Force Custom Plans

If your queries have high parameter sensitivity (different values need radically different plans), force custom plans:

-- Session-level
SET plan_cache_mode = force_custom_plan;

-- Per-query (in a transaction)
BEGIN;
SET LOCAL plan_cache_mode = force_custom_plan;
EXECUTE get_by_status('error');
COMMIT;

-- Permanent (all connections)
ALTER DATABASE mydb SET plan_cache_mode = force_custom_plan;

Trade-off: You pay planning overhead on every execution. For simple queries, this is negligible (sub-millisecond). For complex queries with many joins, it can be significant.

Force Generic Plans

If your queries always benefit from the same plan regardless of parameter values (e.g., INSERT ... VALUES ($1, $2, $3)), force generic plans to skip the planning overhead:

SET plan_cache_mode = force_generic_plan;

This is ideal for:

  • Simple INSERTs and UPDATEs with no WHERE clause variability
  • Queries where the plan doesn’t depend on parameter values
  • High-throughput OLTP workloads where planning overhead is a bottleneck

Auto (Default)

For most workloads, auto works fine. The heuristic is good when statistics are accurate. If you’re hitting problems, the fix is often:

-- Update statistics first
ANALYZE orders;

-- Then check if the heuristic works better now

Where This Shows Up in the Wild

ORMs and Connection Poolers

Most ORMs (ActiveRecord, Django ORM, Hibernate) use prepared statements under the hood. PgBouncer in transaction-mode pooling can also affect plan caching behavior because prepared statements are session-scoped — they’re lost when the connection is returned to the pool.

PostgreSQL 14+: The pg_prepared_statements view shows from_sql (true for SQL-level PREPARE) vs from_options (extended query protocol).

PL/pgSQL

Every PL/pgSQL function with parameterized queries uses the same generic/custom plan mechanism internally:

CREATE OR REPLACE FUNCTION get_user_orders(p_user_id int)
RETURNS SETOF orders AS $$
BEGIN
  -- This uses prepared-statement planning internally
  RETURN QUERY
    SELECT * FROM orders WHERE user_id = p_user_id;
END;
$$ LANGUAGE plpgsql;

If you see a PL/pgSQL function that’s fast for some parameter values and slow for others, it might be stuck on a generic plan. The fix is the same: SET plan_cache_mode = force_custom_plan inside the function, or at the session level.

Partitioned Tables

Generic plans with partitioned tables can be particularly painful. The planner needs to know the parameter value to prune partitions. A generic plan might scan all partitions:

PREPARE range_query(date, date) AS
  SELECT * FROM orders_partitioned
  WHERE created_at BETWEEN $1 AND $2;

-- Generic plan: scans ALL partitions
-- Custom plan: prunes to only relevant partitions

Partition pruning at plan time (static pruning) requires knowing the actual values. A generic plan can only do execution-time pruning, which is less effective.

The Deeper Problem: Parameter-Sensitive Plans

This entire issue is a specific case of a broader database challenge: parameter-sensitive plan optimization. The ideal plan depends on the parameter values, but a single cached plan can only represent one strategy.

Other databases handle this differently:

  • SQL Server: Has “parameter sniffing” with automatic recompilation thresholds
  • Oracle: Uses “bind peeking” and adaptive cursor sharing
  • PostgreSQL: Uses the five-execution heuristic described above

PostgreSQL’s approach is conservative and simple. It doesn’t try to maintain multiple plans for different parameter value ranges. The trade-off is clear: simplicity and predictability vs. optimal performance for all parameter values.

Practical Diagnostic Queries

1. Find prepared statements with suspicious plan choices

SELECT name, query, generic_plans, custom_plans,
       CASE WHEN generic_plans > custom_plans
            THEN 'mostly generic'
            ELSE 'mostly custom' END AS plan_type
FROM pg_prepared_statements
ORDER BY generic_plans DESC;

2. Measure planning vs execution time

-- Check how much time is spent planning vs executing
EXPLAIN (ANALYZE, SUMMARY) EXECUTE my_stmt('param');
-- Look for "Planning Time:" vs "Execution Time:"

If planning time is <1ms, forcing custom plans is essentially free.

3. Test both modes side by side

-- Benchmark: custom plans
DO $$
DECLARE
  start_ts timestamptz;
  total_ms float := 0;
BEGIN
  FOR i IN 1..100 LOOP
    SET LOCAL plan_cache_mode = force_custom_plan;
    start_ts := clock_timestamp();
    PERFORM * FROM orders WHERE status = 'error';
    total_ms := total_ms + extract(milliseconds from clock_timestamp() - start_ts);
  END LOOP;
  RAISE NOTICE 'Custom plan total: % ms', total_ms;
END;
$$;

4. Check if statistics are the real problem

-- Check if n_distinct is accurate
SELECT attname, n_distinct, most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';

If most_common_vals doesn’t include your rare values, the planner can’t make good decisions for them.

Key Takeaways

  1. Prepared statements skip parsing/analysis but still need planning — and that’s where generic vs custom comes in.
  2. Custom plans use actual parameter values → optimal plans but planning overhead on every execution.
  3. Generic plans ignore parameter values → reusable but potentially suboptimal.
  4. The five-execution heuristic compares estimated costs (not actual times), so stale statistics can cause bad decisions.
  5. plan_cache_mode lets you override: force_custom_plan, force_generic_plan, or auto (default).
  6. PL/pgSQL functions use the same mechanism — a slow function might be stuck on a generic plan.
  7. Partition pruning is especially sensitive — generic plans can’t do static partition pruning.
  8. Before changing plan_cache_mode, try ANALYZE first — accurate statistics make the auto heuristic work much better.

What’s Next

Tomorrow we’ll look at parallel query execution — how PostgreSQL uses multiple CPU cores for a single query, the Gather/Gather Merge nodes, and why parallel queries sometimes make things slower.

This is Day 9 of the PostgreSQL Deep Dive series. Read Day 8 — Nested Loop vs Hash Join vs Merge Join.