Make Query Plans Part of Your Tests in 2025: EXPLAIN Diffs, Plan Fingerprints, and CI Budget Gates
Most teams still treat query performance as something you measure reactively in production, long after the pull request that caused the regression has merged. That posture made sense when EXPLAIN plans were arcane and CI was slow. In 2025, it’s a missed opportunity.
Query plans are just as testable as API responses. And unlike flaky microbenchmarks, plan shape and row estimates can give you stable signals that uncover regressions before they become incidents.
This article shows a practical, opinionated approach to making query plans a first-class citizen of your test suite:
- Capture EXPLAIN plans in CI and compute plan fingerprints.
- Diff plan fingerprints across pull requests.
- Enforce budgets on latency, buffer reads, and row-estimation error.
- Tame plan churn with deterministic stats, hints/baselines where appropriate, and seeded ORM test data.
The examples focus on PostgreSQL because it’s the most common OSS database in modern stacks and has excellent EXPLAIN support. The same principles adapt to MySQL, SQL Server, SQLite, and cloud warehouses.
Why test query plans?
- Plans change the physics of your system. A nested loop that turns into a merge join can turn p50=8 ms into p50=400 ms under the same data set.
- Plans are explainable. Diffing a plan tree tells you precisely what changed (scan type, join order, index choice) instead of just “it got slower.”
- Plans are stable enough to gate on in CI if you control inputs (stats, parameters, data shape). You don’t need a production-sized dataset to catch most regressions.
There’s a misconception that only end-to-end performance tests catch regressions. In practice, most catastrophic slowdowns are plan-choice mistakes driven by data distribution, parameter values, or innocuous-looking ORM changes. Catching those deltas at PR time saves rollbacks, paging, and fire drills.
A quick refresher: EXPLAIN across engines
- PostgreSQL: EXPLAIN produces text, JSON, and XML. With ANALYZE you get real runtime, rows, and buffer I/O. Docs: https://www.postgresql.org/docs/current/using-explain.html
- MySQL: EXPLAIN FORMAT=JSON shows the plan; Optimizer Trace provides deeper reasoning. Docs: https://dev.mysql.com/doc/refman/8.0/en/explain.html and https://dev.mysql.com/doc/refman/8.0/en/optimizer-trace.html
- SQL Server: SHOWPLAN XML estimates, STATISTICS XML with actuals (when executing). Query Store can freeze plans. Docs: https://learn.microsoft.com/en-us/sql/relational-databases/performance/monitoring-performance-by-using-the-query-store
- SQLite: EXPLAIN QUERY PLAN gives a compact plan shape. Docs: https://www.sqlite.org/eqp.html
- BigQuery: EXPLAIN statement returns a stage/operation tree. Docs: https://cloud.google.com/bigquery/docs/reference/standard-sql/explain
- Snowflake: Query profile provides execution graph and stats. Docs: https://docs.snowflake.com/en/user-guide/ui-query-profile
The idea is universal: capture a structured plan, normalize it to a fingerprint, compare with a baseline, and gate on budgets.
The core loop: plan capture, fingerprinting, budgets, and gates
- Capture plan shape deterministically
- Use EXPLAIN without ANALYZE to fingerprint the plan (so you don’t execute writes in CI). Prefer JSON/XML format for structure.
- Optionally run EXPLAIN ANALYZE for metrics (latency, buffers) against a seed dataset.
- Normalize literals and object names so plan changes caused by schema renames don’t trigger false alarms.
- Compute a plan fingerprint
- Fingerprint only the structural aspects: node types, join types, index usage, join order, key columns, and selected predicates (normalized), ignoring costs and row estimates.
- Produce a short hash (e.g., SHA-256 truncated) that’s stable if the plan’s logical shape is the same.
- Compare to the main branch baseline
- Store baseline fingerprints in-repo alongside tests or in a small registry. Diff fingerprints from PR to baseline.
- Present a readable diff of the plan tree as well, so reviewers see exactly what changed.
- Enforce budgets
- Latency budget (ms) from EXPLAIN ANALYZE. Use multiple iterations and take p95 to smooth noise.
- Buffer/IO budget (e.g., Postgres shared read blocks) to catch cache-sensitive regressions.
- Row-estimation error budget (ratio of actual to estimated rows) to catch cardinality explosions.
- Make deviations explicit in review
- If plan shape changes: require an approval tag such as “plan-change-ok”.
- If latency/IO exceeds budget: fail the job.
- If row-est error exceeds budget: warn or fail depending on severity.
A pragmatic Postgres implementation
Below is a minimal but production-ready pattern for PostgreSQL. It uses EXPLAIN (FORMAT JSON) to fingerprint and EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) to measure.
Recommended Postgres GUCs for stable CI
Set these for your CI database session or instance to control nondeterminism and reduce noise:
- SET jit = off — JIT adds variance (PG 11+; docs: runtime-config-jit)
- SET max_parallel_workers_per_gather = 0 — Eliminate parallelism variance
- SET random_page_cost = 1.1 — Stable cost model
- SET effective_cache_size = '4GB' — Fixed cache assumption
- SET work_mem = '64MB' — Deterministic hash join/agg sizing
- SET plan_cache_mode = force_custom_plan — Avoid generic-plan switching in prepared statements (PG 12+)
- SET track_io_timing = on — Get buffer timing details
- SET statement_timeout = '30s' — Fail catastrophes quickly
In ephemeral CI DBs, disable autovacuum for test tables and run ANALYZE once after loading seed data to freeze stats:
- ALTER TABLE ... SET (autovacuum_enabled = off);
- VACUUM (ANALYZE) target_table;
Also consider fixing default_statistics_target to a known value (e.g., 100) for repeatable histograms:
- ALTER SYSTEM SET default_statistics_target = 100;
Docs:
- EXPLAIN: https://www.postgresql.org/docs/current/using-explain.html
- plan_cache_mode: https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-PLAN-CACHE-MODE
- JIT: https://www.postgresql.org/docs/current/runtime-config-jit.html
- CREATE STATISTICS (extended stats): https://www.postgresql.org/docs/current/sql-createstatistics.html
Data seeding principles
- Generate deterministic data with a fixed RNG seed.
- Mirror key distributions: skewed categorical values, correlation between columns, and occasional outliers. Extended stats (dependencies, mcv) will capture correlations if you CREATE STATISTICS.
- Load enough rows to exercise plan choices (often tens of thousands suffice to flip between index vs. seq scan or NLJ vs. hash join).
Example seed (psql):
sqlCREATE TABLE events ( id bigserial PRIMARY KEY, user_id bigint NOT NULL, org_id bigint NOT NULL, event_type text NOT NULL, created_at timestamptz NOT NULL, payload jsonb NOT NULL ); -- Skew event_type and org_id to induce selective predicates INSERT INTO events (user_id, org_id, event_type, created_at, payload) SELECT (1000000 * random())::bigint, (CASE WHEN random() < 0.8 THEN 42 ELSE (1000 * random())::int END), (CASE WHEN random() < 0.7 THEN 'click' WHEN random() < 0.2 THEN 'view' ELSE 'purchase' END), now() - (random() * interval '90 days'), jsonb_build_object('v', (random()*1000)::int) FROM generate_series(1, 500000); CREATE INDEX ON events (org_id, created_at DESC); CREATE INDEX ON events (event_type); -- Extended stats to help the planner understand org_id, event_type correlation CREATE STATISTICS s_events_org_event (dependencies, mcv) ON org_id, event_type FROM events; VACUUM (ANALYZE) events;
Fingerprinting EXPLAIN (FORMAT JSON)
We’ll hash the structural plan tree and ignore volatile fields like costs and row counts.
Python script (psycopg):
pythonimport hashlib import json import re import sys from dataclasses import dataclass import psycopg LITERAL_RE = re.compile(r"\b\d+\b|\'(?:[^']|\'')*\'") def normalize_predicate(pred: str | None) -> str | None: if not pred: return None # Replace numeric and string literals with ? and canonicalize whitespace s = LITERAL_RE.sub('?', pred) s = re.sub(r"\s+", " ", s.strip()) return s.lower() def node_fingerprint(node: dict): # Keep only structural keys; ignore rows, costs, timing t = node.get("Node Type") jt = node.get("Join Type") rel = node.get("Relation Name") idx = node.get("Index Name") filters = [ normalize_predicate(node.get("Filter")), normalize_predicate(node.get("Index Cond")), normalize_predicate(node.get("Hash Cond")), normalize_predicate(node.get("Merge Cond")), normalize_predicate(node.get("Recheck Cond")), ] filters = tuple(f for f in filters if f) # Sort key columns and output columns when present keys = tuple(sorted(node.get("Index Key", []) + node.get("Output", []))) children = tuple(node_fingerprint(c) for c in node.get("Plans", [])) # Some nodes have relation alias; ignore to reduce churn return (t, jt, rel, idx, keys, filters, children) def plan_fingerprint(plan_json: dict) -> str: plan = plan_json[0]["Plan"] if isinstance(plan_json, list) else plan_json["Plan"] fp_tuple = node_fingerprint(plan) data = json.dumps(fp_tuple, separators=(",", ":")) return hashlib.sha256(data.encode()).hexdigest()[:16] def worst_row_est_error(plan: dict) -> float: """Return worst ratio of estimated vs actual rows across nodes.""" worst = 1.0 def walk(n: dict): nonlocal worst est = max(n.get("Plan Rows", 1), 1) act = max(n.get("Actual Rows", 1), 1) ratio = max(est / act, act / est) worst = max(worst, ratio) for c in n.get("Plans", []): walk(c) walk(plan) return worst @dataclass class Metrics: total_time_ms: float shared_hit: int shared_read: int worst_row_est_ratio: float def plan_metrics(plan_json: dict) -> Metrics: plan_root = plan_json[0]["Plan"] total_time_ms = plan_json[0].get("Execution Time", 0.0) # From BUFFERS on the root node buffers = plan_root.get("Shared Hit Blocks", 0), plan_root.get("Shared Read Blocks", 0) return Metrics( total_time_ms=total_time_ms, shared_hit=buffers[0], shared_read=buffers[1], worst_row_est_ratio=worst_row_est_error(plan_root), ) def explain_json(conn, sql: str, analyze=False, buffers=False) -> dict: parts = ["EXPLAIN (FORMAT JSON"] if analyze: parts.append("ANALYZE true") if buffers: parts.append("BUFFERS true") parts.append(") ") q = " ".join(parts) + sql with conn.cursor() as cur: cur.execute("SET jit=off;") cur.execute("SET max_parallel_workers_per_gather=0;") cur.execute("SET plan_cache_mode='force_custom_plan';") cur.execute(q) row = cur.fetchone() return row[0] def main(dsn: str, sql: str, baseline_fp: str | None = None): with psycopg.connect(dsn, autocommit=True) as conn: plan = explain_json(conn, sql) fp = plan_fingerprint(plan) print(f"Plan fingerprint: {fp}") if baseline_fp and baseline_fp != fp: print("Plan changed!", file=sys.stderr) sys.exit(2) # Run multiple ANALYZE passes for latency and buffers metrics = [] for _ in range(5): p = explain_json(conn, sql, analyze=True, buffers=True) metrics.append(plan_metrics(p)) p95_time = sorted(m.total_time_ms for m in metrics)[-1] worst_row_est = max(m.worst_row_est_ratio for m in metrics) max_read = max(m.shared_read for m in metrics) print(json.dumps({ "fingerprint": fp, "p95_ms": p95_time, "worst_row_est_ratio": worst_row_est, "max_shared_read_blocks": max_read }, indent=2)) if __name__ == "__main__": # Usage: python plan_check.py "postgres://..." "SELECT ..." [baseline_fp] main(sys.argv[1], sys.argv[2], sys.argv[3] if len(sys.argv) > 3 else None)
This script:
- Produces a short, stable plan fingerprint for gating.
- Computes p95 latency across 5 runs.
- Captures the worst row-estimation error ratio across nodes.
- Observes shared read blocks (cold I/O) to detect regressions masked by cache.
Store your baseline fingerprint per query. If a PR changes the fingerprint, reviewers see that signal and can inspect the plan diff.
Plan tree diffs for humans
Add a tree renderer to visually diff plan shape. Here’s a compact textual renderer:
pythondef render_plan_tree(node: dict, indent="") -> list[str]: label = node.get("Node Type") join = node.get("Join Type") rel = node.get("Relation Name") idx = node.get("Index Name") conds = [c for c in [node.get("Index Cond"), node.get("Hash Cond"), node.get("Merge Cond"), node.get("Filter")] if c] head = f"{label}{' '+join if join else ''}{' on '+rel if rel else ''}{' using '+idx if idx else ''}" lines = [indent + head] for c in conds: lines.append(indent + " where " + normalize_predicate(c)) for child in node.get("Plans", []): lines.extend(render_plan_tree(child, indent + " ")) return lines
During CI, if fingerprints differ, print the old/new trees and a unified diff so reviewers can reason about why the shape changed.
Budgets defined next to tests
Keep budgets under version control. A simple budgets.yaml might look like this:
yamlqueries: get_user_feed: sql: | SELECT e.* FROM events e WHERE e.org_id = 42 AND e.event_type = 'purchase' ORDER BY e.created_at DESC LIMIT 50; baseline_fingerprint: "7a9f2f5e1d3a4b67" latency_ms_p95: 35 row_est_ratio_max: 10 shared_read_blocks_max: 10 allow_fingerprint_changes: false
Your CI script iterates budgets and calls the plan checker for each.
GitHub Actions skeleton
yamlname: Plan gates on: pull_request: jobs: explain: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: postgres ports: ['5432:5432'] options: >- --health-cmd "pg_isready -U postgres" --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.11' - name: Install deps run: pip install psycopg[binary] - name: Create schema and seed data run: | psql postgresql://postgres:postgres@localhost:5432/postgres -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;" psql postgresql://postgres:postgres@localhost:5432/postgres -f db/schema.sql psql postgresql://postgres:postgres@localhost:5432/postgres -f db/seed.sql psql postgresql://postgres:postgres@localhost:5432/postgres -c "VACUUM (ANALYZE);" - name: Run plan checks run: | python tools/plan_check.py "postgresql://postgres:postgres@localhost:5432/postgres" "SELECT 1" || true python tools/run_budgets.py budgets.yaml
Your tools/run_budgets.py script reads budgets.yaml, calls plan_check.py, compares metrics to thresholds, and fails the job if needed.
Handling plan churn: stabilize, then allow explicit change
Even with deterministic seeds, plans can churn due to:
- Stats changes (autovacuum analyze, or different sampling)
- Parameter-sensitivity and generic vs custom plans
- Upgrading the engine or enabling new extensions
Tactics to tame churn:
- Freeze stats in CI. Load data, then VACUUM (ANALYZE), and disable autovacuum for test tables.
- Force custom plans: SET plan_cache_mode = force_custom_plan so bind parameters don’t flip between generic/custom.
- Disable parallelism to avoid plan variants.
- Normalize predicate literals in fingerprints to prevent constant-only changes from invalidating fingerprints.
- Adopt hints/baselines sparingly when a specific plan is required for SLOs.
Hints/baselines across engines:
- PostgreSQL: pg_hint_plan provides hints via comments like SELECT /*+ IndexScan(e events_org_id_created_at_idx) */ ...; https://pghintplan.osdn.jp/ or its GitHub mirrors. Use only where unavoidable; prefer statistics/indices to guide planners.
- SQL Server: Query Store FORCE PLAN can freeze a known-good plan; OPTION(HASH JOIN) etc. docs: https://learn.microsoft.com/en-us/sql/relational-databases/performance/query-store-use
- Oracle: SQL Plan Management/Baselines lock in desired plans.
- MySQL: Optimizer hints /*+ INDEX(tbl idx_name) */ and straight_join.
Where hints are not available or desired, consider structural nudges such as:
- Postgres CTE materialization hints (MATERIALIZED/NOT MATERIALIZED) in PG 12+.
- Adding extended stats (CREATE STATISTICS) to teach the planner about cross-column correlation.
Row-estimation budgets: catching the silent killers
Catastrophic regressions often start as cardinality mistakes: the planner believes a predicate is selective when it isn’t, or vice versa. Tolerating a worst-node estimation error of, say, 5–10x in CI will catch “innocent” schema or ORM changes that dramatically mislead the optimizer.
Implementation notes for Postgres:
- EXPLAIN (ANALYZE, FORMAT JSON) provides Plan Rows vs Actual Rows per node.
- Compute the max of max(est/act, act/est) across nodes.
- Gate when the ratio exceeds your threshold.
Then decide: fix with stats (more NDV or MCV), indexes, query rewrites, or targeted hints.
Latency and IO budgets without flakiness
Pure latency gates can be flaky in shared CI. Make them robust:
- Use p95 over 5–7 runs.
- Run the query once to warm caches, then measure subsequent runs if you’re intentionally measuring hot-path latency. If you care about cold start, keep the first run and add IO budgets (shared read blocks in Postgres).
- Pin CPU and memory: run the DB in a dedicated container with limited noisy neighbors; keep the instance small but isolated.
- Fail only with comfortable margins (e.g., >20–30% over budget), warn otherwise.
ORMs: capturing and testing the queries you actually generate
Most production SQL originates from ORMs. Instead of guessing, wire ORM-level instrumentation to collect queries and run EXPLAIN in tests.
Patterns:
- Django: capture connection.queries in tests; or use cursor wrapper to EXPLAIN selected statements. For deterministic plans, pass literal parameter values typical of the hot paths.
- Rails ActiveRecord: subscribe to sql.active_record notifications; EXPLAIN via connection.explain for target queries.
- Hibernate: enable statistics and intercept SQL via StatementInspector; run EXPLAIN using JDBC before execution in a dedicated “plan test” profile.
- Prisma/TypeORM/Sequelize: use query event hooks to extract SQL and parameters; route selected queries through EXPLAIN in a test harness.
Seeded data matters more than volume. Ensure your factories create the distributions (skew, correlations) that decide plan shape.
MySQL, SQL Server, SQLite: adapting the pattern
-
MySQL
- Use EXPLAIN FORMAT=JSON for structure; compute fingerprints similarly.
- Optimizer Trace can help debug why a plan changed.
- Hints exist and are pragmatically useful for plan stability: /*+ INDEX, HASH_JOIN, NO_MERGE */ etc.
-
SQL Server
- Use SET SHOWPLAN_XML ON to get estimated plans without executing; use SET STATISTICS XML ON for actuals.
- Query Store provides per-query baselining and FORCE PLAN when you need it.
- For budgets, read actual rows and time from STATISTICS XML; compare to thresholds.
-
SQLite
- Use EXPLAIN QUERY PLAN: it returns a concise tree; hash opcodes and tables/indexes used.
- No ANALYZE timings, so rely on plan stability and row count budgets in the app layer if needed.
-
BigQuery and Snowflake
- CI cost/time can be high. Consider plan-only gating (EXPLAIN/PROFILE without executing large datasets) and run latency gates in nightly jobs against a small but representative dataset.
Example: end-to-end budget runner for Postgres
A simple runner that reads budgets.yaml, prints diffs, and fails with actionable output.
pythonimport yaml import subprocess import tempfile from difflib import unified_diff from plan_check import explain_json, plan_fingerprint, render_plan_tree, plan_metrics import psycopg DSN = "postgresql://postgres:postgres@localhost:5432/postgres" def run_query_budget(conn, name, cfg): sql = cfg["sql"] baseline_fp = cfg.get("baseline_fingerprint") allow_change = cfg.get("allow_fingerprint_changes", False) plan = explain_json(conn, sql) fp = plan_fingerprint(plan) if baseline_fp and fp != baseline_fp and not allow_change: old_tree = "\n".join(render_plan_tree(plan[0]["Plan"])) # If you store baseline trees, load them; else warn and print current tree print(f"[FAIL] {name}: Plan fingerprint changed: {baseline_fp} -> {fp}") print("Current plan tree:\n" + old_tree) return False # Gather metrics runs = [plan_metrics(explain_json(conn, sql, analyze=True, buffers=True)) for _ in range(5)] p95 = sorted(r.total_time_ms for r in runs)[-1] worst = max(r.worst_row_est_ratio for r in runs) read_blocks = max(r.shared_read for r in runs) ok = True if "latency_ms_p95" in cfg and p95 > cfg["latency_ms_p95"] * 1.2: print(f"[FAIL] {name}: p95 {p95:.1f}ms exceeds budget {cfg['latency_ms_p95']}ms (20% margin)") ok = False if "row_est_ratio_max" in cfg and worst > cfg["row_est_ratio_max"]: print(f"[WARN] {name}: worst row-est ratio {worst:.1f} exceeds {cfg['row_est_ratio_max']}") if "shared_read_blocks_max" in cfg and read_blocks > cfg["shared_read_blocks_max"]: print(f"[FAIL] {name}: shared read blocks {read_blocks} exceeds {cfg['shared_read_blocks_max']}") ok = False if ok: print(f"[OK] {name}: fp={fp} p95={p95:.1f}ms worst_est={worst:.1f} read_blocks={read_blocks}") return ok def main(): with open("budgets.yaml") as f: budgets = yaml.safe_load(f) all_ok = True with psycopg.connect(DSN, autocommit=True) as conn: for name, cfg in budgets["queries"].items(): if not run_query_budget(conn, name, cfg): all_ok = False if not all_ok: raise SystemExit(1) if __name__ == "__main__": main()
When to approve plan changes
Not all plan diffs are bad. Legitimate changes include:
- You added a composite index and the planner now uses it.
- You rewrote a query to push down a filter earlier.
- You upgraded Postgres and got a smarter plan.
The key is explicitness:
- Review the diff. If the new plan is strictly better, update the baseline_fingerprint and, if applicable, tighten budgets.
- If it’s worse but acceptable temporarily, allow change and set a ticket to address with stats/indexes.
- If it’s worse and accidental, fix before merging.
This preserves velocity while making performance a code review artifact instead of a production surprise.
Practical pitfalls and how to avoid them
- Parameter sensitivity: a plan that’s great for small parameter values may be awful for large ones. Either set plan_cache_mode=force_custom_plan in CI to emulate typical bind values, or test several representative parameter sets.
- Write queries: EXPLAIN ANALYZE will execute them. Use EXPLAIN without ANALYZE for fingerprinting and consider running ANALYZE only in a dedicated test DB snapshot that you can discard.
- Nondeterministic functions: queries using random(), now(), or volatile CTEs can change shape; freeze inputs or replace with constants in tests.
- Schema churn: auto-generated index names will perturb fingerprints. Normalize object names in your fingerprinting or use stable index names.
- Parallel plans: disable in CI to reduce variance; re-enable in staging/perf tests for realism.
A note on scientific rigor and noise
If you want stronger statistical guarantees, apply simple nonparametric tests across multiple runs (e.g., Mann–Whitney U) to decide if a latency shift is significant. For most CI use cases, p95-of-5 with a generous margin and IO counters is enough.
Row-estimation budgets are particularly powerful because they’re less noisy and highlight root causes, not just symptoms.
References and further reading
- PostgreSQL EXPLAIN: https://www.postgresql.org/docs/current/using-explain.html
- PostgreSQL plan_cache_mode: https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-PLAN-CACHE-MODE
- PostgreSQL JIT: https://www.postgresql.org/docs/current/runtime-config-jit.html
- PostgreSQL CREATE STATISTICS (extended stats): https://www.postgresql.org/docs/current/sql-createstatistics.html
- auto_explain extension: https://www.postgresql.org/docs/current/auto-explain.html
- pg_stat_statements: https://www.postgresql.org/docs/current/pgstatstatements.html
- MySQL EXPLAIN: https://dev.mysql.com/doc/refman/8.0/en/explain.html
- MySQL Optimizer Trace: https://dev.mysql.com/doc/refman/8.0/en/optimizer-trace.html
- SQL Server Query Store: https://learn.microsoft.com/en-us/sql/relational-databases/performance/monitoring-performance-by-using-the-query-store
- SQLite EXPLAIN QUERY PLAN: https://www.sqlite.org/eqp.html
- BigQuery EXPLAIN: https://cloud.google.com/bigquery/docs/reference/standard-sql/explain
- Snowflake Query Profile: https://docs.snowflake.com/en/user-guide/ui-query-profile
- pg_hint_plan: https://pghintplan.osdn.jp/
The bottom line
In 2025, there’s no reason to ship blind plan changes. Treat EXPLAIN plans like snapshots you review and gate on. Compute plan fingerprints, budget latency/IO/row-est error, and stabilize your CI environment to keep signals clean. Seed your ORM tests with realistic distributions, and use extended stats, indexes, or—when necessary—hints/baselines to keep plans on the rails.
Do this, and performance regressions move from “we’ll find out in prod” to “blocked at PR with a clear diff.” That’s a better place to be for your users, on-call engineers, and velocity alike.