DBA Scripts: Get Query Store Regressions and Forced Plans

🔧Part of the DBA-Tools Project, copy/paste SQL Server scripts and health checks.In: Performance & Troubleshooting › Query & Performance Tuning

“What Changed Today” and “Is the Plan I Forced Still Any Good”

A query that was fine yesterday is slow today, or a plan you forced has stopped protecting it. The first script compares each query’s last 24 hours in Query Store with its own previous 6 days; the second lists every forced plan with its failure count and the reason forcing failed. For what is expensive right now, whatever its history, start with Query Store Top Queries.


Why Query Store Regressions and Forced Plans Matter

  • A query that’s always been slow isn’t a regression, a query that suddenly runs 3x slower than its own 7-day average is, and that distinction needs a baseline comparison, not an absolute CPU or duration threshold
  • Forcing a plan is a deliberate intervention, and it can silently stop working: force_failure_count > 0 means Query Store is reverting to natural plans behind your back, on a query you believe is protected
  • A forced plan that made sense six months ago can be actively holding a query back today, if the optimizer has since found something cheaper, the force is now the problem
  • Neither raises an error. A failed force returns nothing to the application, the query just runs on another plan, and the only push signal is the query_store_plan_forcing_failed Extended Event in the MS Docs Query Store best practices

When to Run These Scripts

  • Right after “queries feel slower today” reports, before assuming it’s a broad server-level issue
  • Routine health checks on any database with Query Store enabled, both scripts are cheap to run regularly
  • Any time you’ve forced a plan, to periodically confirm it’s still both working and still the cheapest option
  • Before and after a statistics update, index change, or data volume shift that could plausibly move plan choice

The Scripts

Both run in the context of the target database (-Database <dbname>). With Query Store OFF they return 1 row with the database name and the ALTER DATABASE that turns it on. READ_ONLY passes that check: the scripts run on whatever was captured before the switch and do not mention it, so when a busy database returns nothing, run Query Store Status first.

Get-QueryStoreRegressions: What Changed in the Last 24 Hours

✓ Verified
  • Tested on: SQL Server 2025 (RTM CU8) 17.0.4075.5, Windows lab instance
  • Last verified: 2026-10-05 (both scripts run, the Example Output is a real run on the lab instance)
  • Permissions: VIEW DATABASE PERFORMANCE STATE in the target database on SQL Server 2022 and later, or VIEW DATABASE STATE; without either the script stops with Msg 262
  • Safety: read-only, impact low
/*
Script Name : Get-QueryStoreRegressions
Category    : performance
Purpose     : Queries that regressed in the last 24 hours vs their 7-day average CPU/duration.
              Uses Query Store time-bucketed runtime stats to detect "what changed today"
              — queries running >2x slower or using >2x more CPU than their recent baseline.
              Run in the context of the target database (-Database <dbname>).
Author      : Peter Whyte (https://sqldba.blog/dba-scripts-get-query-store-regressions-and-forced-plans/)
Requires    : VIEW DATABASE STATE
*/
-- SAFE:ReadOnly
-- IMPACT:Low
SET NOCOUNT ON;

DECLARE @regression_factor DECIMAL(5,2) = 2.0;
DECLARE @min_executions INT = 5;
DECLARE @recent_hours INT = 24;
DECLARE @baseline_days INT = 7;

IF ISNULL((SELECT actual_state_desc FROM sys.database_query_store_options), 'OFF')
   NOT IN ('READ_WRITE', 'READ_ONLY')
BEGIN
    SELECT
        DB_NAME() AS current_database,
        ISNULL((SELECT actual_state_desc FROM sys.database_query_store_options), 'OFF') AS query_store_status,
        'Enable: ALTER DATABASE [' + DB_NAME() + '] SET QUERY_STORE = ON (OPERATION_MODE = READ_WRITE)' AS action;
END
ELSE
BEGIN
    WITH recent_window AS (
        SELECT
            p.query_id,
            SUM(rs.count_executions) AS exec_count,
            AVG(rs.avg_cpu_time) / 1000.0 AS avg_cpu_ms,
            AVG(rs.avg_duration) / 1000.0 AS avg_duration_ms,
            AVG(rs.avg_logical_io_reads) AS avg_logical_reads,
            MAX(rs.max_cpu_time) / 1000.0 AS max_cpu_ms
        FROM sys.query_store_runtime_stats AS rs
        JOIN sys.query_store_runtime_stats_interval AS ri ON ri.runtime_stats_interval_id = rs.runtime_stats_interval_id
        JOIN sys.query_store_plan AS p ON p.plan_id = rs.plan_id
        WHERE ri.start_time >= DATEADD(HOUR, -@recent_hours, GETUTCDATE())
          AND p.is_forced_plan = 0
        GROUP BY p.query_id
        HAVING SUM(rs.count_executions) >= @min_executions
    ),
    baseline_window AS (
        SELECT
            p.query_id,
            SUM(rs.count_executions) AS exec_count,
            AVG(rs.avg_cpu_time) / 1000.0 AS avg_cpu_ms,
            AVG(rs.avg_duration) / 1000.0 AS avg_duration_ms,
            AVG(rs.avg_logical_io_reads) AS avg_logical_reads
        FROM sys.query_store_runtime_stats AS rs
        JOIN sys.query_store_runtime_stats_interval AS ri ON ri.runtime_stats_interval_id = rs.runtime_stats_interval_id
        JOIN sys.query_store_plan AS p ON p.plan_id = rs.plan_id
        WHERE ri.start_time >= DATEADD(DAY, -@baseline_days, GETUTCDATE())
          AND ri.start_time < DATEADD(HOUR, -@recent_hours, GETUTCDATE())
        GROUP BY p.query_id
        HAVING SUM(rs.count_executions) >= @min_executions
    ),
    -- Materialise results so ORDER BY can reference aliases
    regressed AS (
        SELECT
            rw.query_id,
            OBJECT_NAME(q.object_id) AS object_name,
            CAST(rw.avg_cpu_ms AS DECIMAL(10,2)) AS recent_avg_cpu_ms,
            CAST(rw.avg_duration_ms AS DECIMAL(10,2)) AS recent_avg_duration_ms,
            CAST(rw.avg_logical_reads AS DECIMAL(10,0)) AS recent_avg_logical_reads,
            rw.exec_count AS recent_exec_count,
            CAST(bw.avg_cpu_ms AS DECIMAL(10,2)) AS baseline_avg_cpu_ms,
            CAST(bw.avg_duration_ms AS DECIMAL(10,2)) AS baseline_avg_duration_ms,
            CAST(bw.avg_logical_reads AS DECIMAL(10,0)) AS baseline_avg_logical_reads,
            bw.exec_count AS baseline_exec_count,
            CAST(rw.avg_cpu_ms / NULLIF(bw.avg_cpu_ms, 0) AS DECIMAL(6,2)) AS cpu_regression_factor,
            CAST(rw.avg_duration_ms / NULLIF(bw.avg_duration_ms, 0) AS DECIMAL(6,2)) AS duration_regression_factor,
            (SELECT COUNT(DISTINCT plan_id) FROM sys.query_store_plan
             WHERE query_id = rw.query_id) AS total_plan_count,
            CASE
                WHEN rw.avg_cpu_ms > bw.avg_cpu_ms * (@regression_factor * 2)
                THEN 'CRITICAL - CPU ' +
                     CAST(CAST(rw.avg_cpu_ms / NULLIF(bw.avg_cpu_ms, 0) AS INT) AS VARCHAR) +
                     'x baseline; likely plan regression or stats change'
                WHEN rw.avg_duration_ms > bw.avg_duration_ms * (@regression_factor * 2)
                THEN 'CRITICAL - duration ' +
                     CAST(CAST(rw.avg_duration_ms / NULLIF(bw.avg_duration_ms, 0) AS INT) AS VARCHAR) +
                     'x baseline'
                WHEN rw.avg_cpu_ms > bw.avg_cpu_ms * @regression_factor
                THEN 'WARN - CPU ' +
                     CAST(CAST(rw.avg_cpu_ms / NULLIF(bw.avg_cpu_ms, 0) AS DECIMAL(4,1)) AS VARCHAR) +
                     'x baseline'
                WHEN rw.avg_duration_ms > bw.avg_duration_ms * @regression_factor
                THEN 'WARN - duration ' +
                     CAST(CAST(rw.avg_duration_ms / NULLIF(bw.avg_duration_ms, 0) AS DECIMAL(4,1)) AS VARCHAR) +
                     'x baseline'
                ELSE 'INFO'
            END AS regression_status,
            LEFT(qt.query_sql_text, 400) AS query_text
        FROM recent_window AS rw
        JOIN baseline_window AS bw ON bw.query_id = rw.query_id
        JOIN sys.query_store_query AS q ON q.query_id = rw.query_id
        JOIN sys.query_store_query_text AS qt ON qt.query_text_id = q.query_text_id
        WHERE rw.avg_cpu_ms > bw.avg_cpu_ms * @regression_factor
           OR rw.avg_duration_ms > bw.avg_duration_ms * @regression_factor
    )
    SELECT *
    FROM regressed
    ORDER BY
        CASE WHEN regression_status LIKE 'CRITICAL%' THEN 1
             WHEN regression_status LIKE 'WARN%' THEN 2
             ELSE 3 END,
        cpu_regression_factor DESC;
END;

The regression factor (2x) and minimum execution count (5) are declared as variables at the top specifically so they’re easy to tune, a query that only ran twice in the recent window isn’t a reliable regression signal yet. @recent_hours sets the window: the last 24 hours are compared with the 6 days before them, by the UTC start time of each Query Store interval.

Get-QueryStoreForcedPlans: Is the Force Still Working, and Still Worth It

/*
Script Name : Get-QueryStoreForcedPlans
Category    : performance
Purpose     : Forced plans in Query Store with failure counts, plan age, forcing reason,
              and whether the forced plan is still the cheapest available option.
              A force_failure_count > 0 means QS is silently reverting to natural plans —
              queries you think are protected are not.
              Run in the context of the target database (-Database <dbname>).
Author      : Peter Whyte (https://sqldba.blog/dba-scripts-get-query-store-regressions-and-forced-plans/)
Requires    : VIEW DATABASE STATE
*/
-- SAFE:ReadOnly
-- IMPACT:Low
SET NOCOUNT ON;

IF ISNULL((SELECT actual_state_desc FROM sys.database_query_store_options), 'OFF')
   NOT IN ('READ_WRITE', 'READ_ONLY')
BEGIN
    SELECT
        DB_NAME() AS current_database,
        ISNULL((SELECT actual_state_desc FROM sys.database_query_store_options), 'OFF') AS query_store_status,
        'Enable: ALTER DATABASE [' + DB_NAME() + '] SET QUERY_STORE = ON (OPERATION_MODE = READ_WRITE)' AS action;
END
ELSE
BEGIN
    WITH forced AS (
        SELECT
            p.query_id,
            p.plan_id AS forced_plan_id,
            p.force_failure_count,
            p.last_force_failure_reason_desc,
            p.plan_forcing_type_desc,
            p.last_execution_time,
            p.initial_compile_start_time AS plan_created,
            DATEDIFF(DAY, p.initial_compile_start_time, GETDATE()) AS plan_age_days,
            AVG(rs.avg_cpu_time) / 1000.0 AS forced_avg_cpu_ms,
            SUM(rs.count_executions) AS forced_exec_count
        FROM sys.query_store_plan AS p
        JOIN sys.query_store_runtime_stats AS rs ON rs.plan_id = p.plan_id
        WHERE p.is_forced_plan = 1
        GROUP BY
            p.query_id, p.plan_id, p.force_failure_count,
            p.last_force_failure_reason_desc, p.plan_forcing_type_desc,
            p.last_execution_time, p.initial_compile_start_time
    ),
    best_plan AS (
        SELECT
            p.query_id,
            MIN(rs_agg.avg_cpu_ms) AS best_avg_cpu_ms
        FROM sys.query_store_plan AS p
        JOIN (
            SELECT plan_id, AVG(avg_cpu_time) / 1000.0 AS avg_cpu_ms
            FROM sys.query_store_runtime_stats
            GROUP BY plan_id
        ) AS rs_agg ON rs_agg.plan_id = p.plan_id
        GROUP BY p.query_id
    ),
    results AS (
        SELECT
            f.query_id,
            f.forced_plan_id,
            OBJECT_NAME(q.object_id) AS object_name,
            LEFT(qt.query_sql_text, 400) AS query_text,
            f.plan_forcing_type_desc,
            f.plan_age_days,
            f.force_failure_count,
            f.last_force_failure_reason_desc,
            f.last_execution_time,
            CAST(f.forced_avg_cpu_ms AS DECIMAL(10,2)) AS forced_avg_cpu_ms,
            CAST(bp.best_avg_cpu_ms AS DECIMAL(10,2)) AS best_available_avg_cpu_ms,
            CASE
                WHEN bp.best_avg_cpu_ms < f.forced_avg_cpu_ms * 0.8
                THEN CAST(CAST(100.0 * (f.forced_avg_cpu_ms - bp.best_avg_cpu_ms)
                         / NULLIF(bp.best_avg_cpu_ms, 0) AS INT) AS VARCHAR) +
                     '% cheaper plan available - consider unforcing and testing'
                ELSE 'OK - forced plan remains competitive'
            END AS plan_quality,
            CASE
                WHEN f.force_failure_count > 0
                THEN 'CRITICAL - force is FAILING (' + CAST(f.force_failure_count AS VARCHAR) +
                     ' failures, reason: ' + f.last_force_failure_reason_desc +
                     '); query is running without the forced plan'
                WHEN f.plan_age_days > 180
                THEN 'WARN - plan forced > 6 months ago; re-evaluate whether force is still needed'
                WHEN bp.best_avg_cpu_ms < f.forced_avg_cpu_ms * 0.8
                THEN 'WARN - cheaper plan now exists in QS; forced plan may be holding back performance'
                ELSE 'OK'
            END AS status
        FROM forced AS f
        JOIN sys.query_store_query AS q ON q.query_id = f.query_id
        JOIN sys.query_store_query_text AS qt ON qt.query_text_id = q.query_text_id
        JOIN best_plan AS bp ON bp.query_id = f.query_id
    )
    SELECT *
    FROM results
    ORDER BY
        CASE WHEN status LIKE 'CRITICAL%' THEN 1
             WHEN status LIKE 'WARN%' THEN 2
             ELSE 3 END,
        force_failure_count DESC,
        plan_age_days DESC;
END;

force_failure_count > 0 is the single most important thing this script surfaces: it means Query Store has been silently falling back to a natural plan because it couldn’t apply the forced one. The application gets no error, so this column is where it shows.


How To Run From The Repo

Clone DBA Tools, initialize and run either script against a database with Query Store enabled:

# Clone dba-tools repo:
git clone https://github.com/peterwhyte-lgtm/dba-tools

# Initialize environment:
cd dba-tools
.\Initialize-Environment.ps1

# Queries that regressed in the last 24 hours vs their 7-day baseline:
.\run.ps1 Get-QueryStoreRegressions -Database YourDatabase

# Forced plans, failure counts, and whether they're still the best option:
.\run.ps1 Get-QueryStoreForcedPlans -Database YourDatabase

# To run against a remote sql server:
.\run.ps1 Get-QueryStoreRegressions -ServerInstance SQLSERVER01 -Database YourDatabase

These scripts live in the repo at:


Example Output

Get-QueryStoreRegressions

A lab procedure ran 300 times for a customer with 10 orders on an index seek. It was then recompiled for a customer with 300,000 orders and ran 21 times on the scan plan that compile left behind, 20 of them for the small customer. Run with @recent_hours = 1 so the earlier runs fall in the baseline; one row, shown one column per line:

Get-QueryStoreRegressions on the lab instanceexample output, not something to copy
query_id 17 object_name usp_CustomerOrderTotal recent_avg_cpu_ms 190.74 recent_avg_duration_ms 132.27 recent_avg_logical_reads 9936 recent_exec_count 21 baseline_avg_cpu_ms .08 baseline_avg_duration_ms .09 baseline_avg_logical_reads 33 baseline_exec_count 300 cpu_regression_factor 2511.37 duration_regression_factor 1462.14 total_plan_count 3 regression_status CRITICAL - CPU 2511x baseline; likely plan regression or stats change query_text (@CustomerId int)SELECT COUNT(*) AS orders, SUM(Amount) AS total, MAX(Filler) AS f FROM dbo.Orders WHERE CustomerId = @CustomerId

CPU per run went from 0.08 ms to 190.74 ms and reads from 33 to 9,936 for a customer who still had 10 orders: a plan change, not more data. At 2511x the factor is already a quarter of the way to the 9999.99 its column can hold.

Get-QueryStoreForcedPlans

A plan using an index seek was forced with sp_query_store_force_plan, then the index it seeks on was dropped. One row, shown one column per line:

Get-QueryStoreForcedPlans on the lab instanceexample output, not something to copy
query_id 30 forced_plan_id 35 object_name usp_ItemsByCategory query_text (@Category int)SELECT COUNT(*) AS items, SUM(Price) AS total, MAX(Filler) AS f FROM dbo.Items WHERE Category = @Category plan_forcing_type_desc MANUAL plan_age_days 0 force_failure_count 2 last_force_failure_reason_desc NO_INDEX last_execution_time 18:31:07 +00:00 forced_avg_cpu_ms 54.00 best_available_avg_cpu_ms 54.00 plan_quality OK - forced plan remains competitive status CRITICAL - force is FAILING (2 failures, reason: NO_INDEX); query is running without the forced plan

The status is CRITICAL while plan_quality still reads OK, because they are separate checks. NO_INDEX means an index the forced plan uses no longer exists. The 2 failures are 2 recompiles: the query ran 6 times on its scan plan meanwhile, and last_execution_time on the forced plan stopped moving.


Understanding the Results

Get-QueryStoreRegressions

regression_status
CRITICAL is more than 4x the baseline, double @regression_factor; WARN is more than 2x. CPU is tested before duration, and on CRITICAL rows the multiple in the text is cut to a whole number, so 4.9x reads as 4x.Act when it says CRITICAL. Open the query in the Regressed Queries view in SSMS (MS Docs) and compare its plans before the next busy period.
cpu_regression_factor
duration_regression_factor
Recent average divided by baseline average. Each average is the plain mean of Query Store’s per-interval averages, not weighted by executions, so a quiet interval counts as much as a busy one.Act when duration rose and CPU did not. That is waiting, blocking or I/O, not a worse plan, so look at what the query waits on before touching the plan.
recent_exec_count
baseline_exec_count
Executions in the last 24 hours and in the 6 days before. A query needs at least 5 in both to appear, and executions of a forced plan are left out of the recent count.Act when a query you know is slow is missing. It may be new, re-created (Query Store gives a re-created procedure a new query_id, MS Docs), or slow for longer than the baseline: Query Store Top Queries has no baseline to meet.
total_plan_count
Every plan Query Store holds for the query, of any age. On the lab it read 3 for a query that had run on 2 plans: the third had been compiled and never run.Act when it is more than 1. A plan change is the likely cause, often parameter sniffing: force the plan from the baseline with sp_query_store_force_plan, then check it holds with the second script.
object_name
The procedure, function or trigger the statement belongs to, NULL for ad hoc SQL. It is also NULL when the login running the script cannot see the object’s definition, and for parameter-sensitive plan variants on SQL Server 2022 and later, which Query Store stores as separate queries with no object.

Get-QueryStoreForcedPlans

status
force_failure_count
last_force_failure_reason_desc
How many times forcing this plan has failed, and why the last time did. The count rises once per recompile, not per execution, and resets to 0 when the plan is forced again; the full list of reasons is on sys.query_store_plan (MS Docs). Fixing the cause does not clear it: with the index re-created, the forced shape ran again under a new plan_id and this row still read CRITICAL, NO_INDEX.Act when status is CRITICAL. The query is running on whatever plan the optimizer picks. Put back what the reason names, then unforce and force the plan again so the count starts from 0.
plan_quality
forced_avg_cpu_ms
best_available_avg_cpu_ms
The forced plan’s average CPU against the cheapest plan Query Store holds for the query; more than 20% cheaper triggers the cheaper-plan message. It is CPU only, averaged across every parameter value. On the lab it read OK while the forced seek plan used 210 ms of CPU per run for the largest category, against 69 to 80 ms per run on the scan plan.Act when the query takes values of very different sizes. Compare each plan’s reads for the large values before trusting OK.
plan_age_days
Days since the forced plan was first compiled, not since it was forced: Query Store keeps no forcing date. The WARN at 180 days is a prompt to re-test, not a fault.
last_execution_time
When the forced plan last ran. While forcing fails it stops moving, because the query runs on other plans.Act when it is well behind the query’s own last run in Query Store Top Queries. The force is not being used.

Best Practices

  • Run Get-QueryStoreRegressions as a routine daily check, not just when someone reports slowness, catching a regression early is cheaper than diagnosing it after a peak-hours incident
  • Treat any forced plan with force_failure_count > 0 as an active incident, the query is running unprotected right now
  • Re-review forced plans periodically, not just when they’re first created, both the optimizer’s options and the data underneath a query change over time
  • Pair a regression finding with Query Performance Deep-Dive when the cause isn’t obvious from Query Store alone

MS Docs: sys.query_store_runtime_stats · sys.query_store_plan · sys.database_query_store_options · Query Store best practices


Related Scripts

You may also find these scripts useful:


Frequently Asked Questions

Why compare against a 7-day baseline instead of a fixed threshold?

A fixed threshold either misses queries that are naturally variable or false-alarms on queries that are consistently a little slow by design. Comparing against the query’s own recent history catches a genuine change in behavior regardless of what “normal” looks like for that specific query. The baseline is the 6 days before the last 24 hours, so a regression older than a day is already part of what it is compared with.

Does force_failure_count mean the forced plan is invalid?

Not necessarily invalid, but currently inapplicable, usually because of a schema change, a dropped index the plan depended on, or a parameter type mismatch. last_force_failure_reason_desc gives the specific reason, worth checking before assuming the plan itself needs to change. Dropping the index a forced seek plan used gave NO_INDEX; re-creating it with the same name let the forced shape run again, but the count stayed at 2 until the plan was forced again.

Why does Get-QueryStoreRegressions return nothing when users say it is slow?

The query has to have run at least 5 times in the last 24 hours and 5 times in the 6 days before. A new query, a procedure that was dropped and re-created, or a regression older than the baseline has nothing to compare with. A forced plan is left out of the recent window. On SQL Server 2022 and later, parameter-sensitive plan optimization can split one statement into variants with their own query_id, each with fewer executions. And if Query Store has gone READ_ONLY, nothing run since is captured: on the lab, 20 runs after the switch left the execution count at 321, and the script returned the same row as before without mentioning it.

Why does Get-QueryStoreRegressions fail with Msg 8115?

cpu_regression_factor and duration_regression_factor are DECIMAL(6,2), which holds up to 9999.99. A query that went from 0.01 ms to 100 ms is 10,000x, and converting 10000.0 to DECIMAL(6,2) raises Msg 8115, Arithmetic overflow error converting numeric to data type numeric, which stops the whole result. Change both casts to DECIMAL(12,2).

What permission do these scripts need?

VIEW DATABASE PERFORMANCE STATE in the target database on SQL Server 2022 and later, which VIEW DATABASE STATE includes. A login with neither gets Msg 262, VIEW DATABASE PERFORMANCE STATE permission denied in database, not an empty result. With it, the rows came back but object_name was NULL, because OBJECT_NAME only names objects the login can see.

A forced plan I know about is missing from Get-QueryStoreForcedPlans. Why?

The script joins each forced plan to its runtime stats, so a forced plan with none left drops out. A force that keeps failing never adds new stats to its own plan, and Query Store removes old runtime stats after stale_query_threshold_days, 30 by default. On the lab, clearing the stats of a failing forced plan took the result from 1 CRITICAL row to 0 rows. List every forced plan directly with SELECT query_id, plan_id, force_failure_count, last_force_failure_reason_desc FROM sys.query_store_plan WHERE is_forced_plan = 1;


Summary

“Slower than usual” and “the plan I forced isn’t working” are both invisible without specifically checking Query Store’s own history, and neither raises an error. These two scripts turn both into a routine check: what regressed against its own baseline in the last day, and whether every forced plan is actually still forcing and still worth forcing.

Run both as part of a regular Query Store review, and treat a failing force or a critical regression as worth acting on the same day it’s found, not at the next scheduled review.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *