Reproducing Production Query Plans in Staging

The migration added an index and dropped an old one that “nothing used”. In staging, every important query still used an index scan afterwards. In production, the checkout query flipped to a sequential scan over forty million rows the moment the old index disappeared, and p99 latency went from 8 ms to 3 seconds. Nobody made a mistake reading the plans; the staging plans were simply not the production plans. A query planner chooses based on table statistics, configuration and data distribution, and a staging database with a tenth of the data, default settings and fresh statistics will make different choices. This guide shows how to make staging predict production’s plans closely enough that index and schema changes can be validated before they ship. It is a specific application of Environment Parity Strategies.

The Three Inputs to a Plan The query planner combines three inputs to choose a plan: table and column statistics (row counts, most common values, histograms, correlation), planner configuration (random_page_cost, effective_cache_size, work_mem, optimizer_switch), and the schema itself (available indexes and constraints). Staging must match all three for EXPLAIN to predict production. The Three Inputs to a Plan Statistics row counts, MCVs, histograms, correlation Planner settings random_page_cost, work_mem, cache size Schema indexes, constraints — what migrations change Query plan index scan, seq scan, join order
A migration changes only the schema input; if staging's statistics or settings differ, the plan you verify is not the plan production will run.

Symptom / Error Signatures

Plan disparity usually shows up only after a deploy:

  • After a migration that adds or drops an index, pg_stat_statements shows a query’s mean_exec_time jumping by an order of magnitude in production while staging load tests were flat.
  • EXPLAIN for the same query shows Index Scan in staging and Seq Scan or a different join order in production.
  • Estimated row counts in staging’s plan (rows=) are orders of magnitude off production’s actual row counts.
  • On MySQL, EXPLAIN FORMAT=JSON shows a different access_type or key between environments, or optimizer_trace reveals different cost estimates.
  • A dropped index that pg_stat_user_indexes.idx_scan reported as unused in staging was used in production.

Root Cause Analysis

Both PostgreSQL and MySQL use cost-based optimizers: they estimate the cost of candidate plans and pick the cheapest. Estimates come from statistics — table row counts, the fraction of NULLs, most-common values and histograms per column, and physical correlation between column order and table order — combined with configuration that sets the relative cost of random and sequential I/O and how much memory the plan may use. A plan is only as reproducible as those inputs.

Input Typical staging difference Effect on plans
Data volume 1–10% of production small tables favour sequential scans; index choice differs
Data distribution synthetic or anonymised uniformly selectivity estimates wrong for skewed columns
Statistics freshness analysed after load, production analysed incrementally different histograms and MCV lists
default_statistics_target / per-column targets default 100 vs tuned coarser histograms
random_page_cost, effective_cache_size defaults for spinning disks index scans look expensive
work_mem, join_collapse_limit defaults different join and sort strategies
MySQL optimizer_switch, histograms defaults, no histograms different access paths

Volume matters less than people expect, and distribution and settings matter more. A staging database with the same statistics and settings as production will usually choose the same plan even with less data, which is why the most effective techniques transplant statistics and configuration rather than copying full datasets.

How Far Off Staging Estimates Were, by Cause Bar chart of the median factor by which staging row estimates differed from production actuals in a sample of mismatched plans, grouped by root cause. Default planner settings, 3x. Stale or missing statistics, 12x. Uniform synthetic data on skewed columns, 40x. Volume alone, 1.8x. How Far Off Staging Estimates Were, by Cause volume only (10% of prod) 1.8× default planner settings stale / missing statistics 12× uniform data on skewed columns 40× median factor between estimated and actual rows (illustrative)
Skewed data replaced by uniform synthetic values produces the largest estimate errors; raw volume is the smallest factor.

Immediate Mitigation

1. Capture the production plan before you change anything. Run EXPLAIN (without ANALYZE, so the query does not execute) on a production replica for each query the migration could affect, and save the output as the reference.

-- PostgreSQL · read-only · run on a production replica · EXPLAIN without ANALYZE does not execute the query
EXPLAIN (FORMAT TEXT, SETTINGS)
SELECT o.id, o.total FROM orders o
WHERE o.customer_id = 42 AND o.status = 'pending'
ORDER BY o.created_at DESC LIMIT 20;

The SETTINGS option (PostgreSQL 12+) prints every non-default planner setting that affected the plan — the quickest way to spot a configuration difference.

2. Copy planner settings to staging. Match random_page_cost, effective_cache_size, work_mem, default_statistics_target and any per-column statistics targets. Settings can be applied per database so staging’s other workloads are unaffected.

-- PostgreSQL · staging only · superuser or database owner
-- WARNING: values must mirror production's, not be tuned for staging's hardware.
ALTER DATABASE app SET random_page_cost = 1.1;
ALTER DATABASE app SET effective_cache_size = '48GB';
ALTER DATABASE app SET work_mem = '32MB';
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;
-- ROLLBACK PATH: ALTER DATABASE app RESET ALL;

3. Test hypothetical indexes without building them. The hypopg extension lets you create an index that exists only for the planner, so you can see whether production’s planner would use it — on a production replica, without any build cost.

-- PostgreSQL · requires the hypopg extension · hypothetical indexes live only in this session
CREATE EXTENSION IF NOT EXISTS hypopg;
SELECT * FROM hypopg_create_index('CREATE INDEX ON orders (customer_id, status, created_at DESC)');
EXPLAIN SELECT id, total FROM orders WHERE customer_id = 42 AND status = 'pending'
ORDER BY created_at DESC LIMIT 20;
SELECT hypopg_reset();

4. On MySQL, align optimizer switches and build histograms. Compare SELECT @@optimizer_switch between environments and create histograms on skewed, unindexed columns that appear in filters.

-- MySQL 8.0 · staging and production · ANALYZE TABLE ... UPDATE HISTOGRAM reads the table, schedule off-peak
ANALYZE TABLE orders UPDATE HISTOGRAM ON status, region WITH 256 BUCKETS;
EXPLAIN FORMAT=TREE SELECT id, total FROM orders WHERE customer_id = 42 AND status = 'pending';

Permanent Fix / Long-Term Pattern

Make plan parity a property of staging rather than a one-off effort. Keep planner configuration in the same configuration management as production and apply it to staging automatically. Refresh staging data from production with anonymisation that preserves distribution — skew, NULL fractions and correlations — as described in seeding anonymized production data into staging, and run ANALYZE afterwards with the same statistics targets. Where full refreshes are too expensive, transplant statistics instead: PostgreSQL 18 added functions for restoring relation and column statistics so a schema-only copy can plan like production, and MySQL histograms can be recreated from production’s information_schema.COLUMN_STATISTICS.

Then add plan checks to the migration pipeline. For each migration that adds or drops an index, run EXPLAIN for a curated list of critical queries before and after in staging, and fail if a plan changes from an index scan to a sequential scan on a large table. Use pg_stat_statements on production to build that list from the queries that actually matter, as in tracking schema migration metrics and SLOs. Before dropping any index, confirm on production — not staging — that idx_scan has not increased over a full business cycle, as covered in dropping indexes online without blocking queries.

Plan Regression Gate for Index Migrations Pipeline for a migration that changes indexes. Capture baseline plans for critical queries in staging; apply the migration; a gate compares new plans, failing on any change to a sequential scan on a large table; a second gate confirms on a production replica with hypothetical indexes; then deploy. Plan Regression Gate for Index Migrations Baseline plans critical queries, staging Apply migration staging plans new seq scans? Replica check hypopg on prod replica agree same plan? Deploy production block: plan regression fix staging parity fail
The gate compares plans, not timings — plan shape is stable in staging even when timings are not.

Verification Checklist

Frequently Asked Questions

Do I need a full production-sized copy for plans to match? Usually not. Planners are driven by statistics and settings more than raw size, so a smaller dataset with production-like distribution, matching settings and analysed statistics reproduces most plans. Full-size copies are still valuable for timing and lock-duration tests.

Is it safe to run EXPLAIN on production? Plain EXPLAIN only plans the query and does not execute it, so it is safe and cheap. EXPLAIN ANALYZE executes the query, including any writes, and should be run on production only for read-only queries and ideally on a replica.

What is hypopg and is it safe on a replica? It is a PostgreSQL extension that creates hypothetical indexes visible only to the planner in the current session. Nothing is written to disk, so it is safe on a replica, provided the extension is installed there — which requires installing it on the primary, since replicas mirror the primary’s catalog.

Why did an index look unused in staging but was used in production? Because staging’s workload and statistics differ. Queries that run in production may never run in staging, and planner choices differ with data distribution. Always base index-drop decisions on production usage counters.