Query Was Fast Yesterday, Slow Today: Find What Changed

🔧Part of the DBA-Tools Project, copy/paste SQL Server scripts and health checks.In: Performance & Troubleshooting › Query & Performance Tuning
⚡If it is not blocked, it is usually a new plan. Run Get Query Store Regressions in the database, then the plan timeline query for the query it names. Two plans with a clean handover time means the plan changed, and the time tells you what to look at: a statistics update, an index rebuild, a restart or a failover. Forcing the old plan buys you the morning. It is not the fix, and on this page it made a different call more than 50 times worse.

The query was fast yesterday and slow today. One procedure went from milliseconds to seconds overnight, nobody deployed anything, the server looks bored, and the developer swears the code has not changed. They are right, it has not. The plan has.

SQL Server does not keep a plan forever. A statistics update, an index rebuild, a restart, a failover or plain memory pressure throws it away, and the next caller compiles a new one with whatever parameter value it happens to bring. This page is the order to check it in, with a reproduction on the lab instance so every number below is real. If the whole server is slow rather than one query, SQL Server Is Slow: What to Check First is the page you want.


STEP1

Step 1: Make Sure It Is Slow and Not Blocked

A query stuck behind someone else’s lock looks exactly like a query that got slower, and no amount of plan work will touch it. Before anything else, catch it while it runs and read the blocking_session_id. A value there means you are in a blocking incident, and Troubleshoot SQL Server Blocking, Step by Step is the page for it. Get Active Sessions and Requests gives you the request, its wait and its blocker in one row.

If it is running, not waiting on a lock, and it is still slow, carry on. The other quick ones to rule out: is it the same query at all (an application sends it parameterised, you paste it with literals), and is it fast in SSMS but slow from the application, which is a different investigation with its own cause.


STEP2

Step 2: Did the Plan Change?

This is the question that decides the rest, and Query Store answers it with dates. Get Query Store Regressions and Forced Plans compares every query’s last 24 hours against its own 7 day baseline and names the ones running more than twice as slow or twice as hungry. It is read-only and runs in the context of the database. If it says Query Store is off, skip to If Query Store Is Off.

The lab setup: a 200,000 row Orders table where customer 1 owns 100,000 rows and every other customer owns 2, a nonclustered index on CustomerId that does not cover the query, and a procedure that totals one customer’s orders. The normal workload is small customers, and they get an index seek at 8 logical reads. One hour later, standing in for overnight, 6,000 orders were loaded for customer 1, the statistics job ran, and the first caller after it was customer 1. The script was run with @recent_hours = 1 instead of 24 so the lab hour counts as “yesterday”:

Get-QueryStoreRegressions on the lab instance, query_text left outreal example output, not something to copy
query_id 9 object_name usp_CustomerOrderSummary recent_avg_cpu_ms 20.48 recent_avg_duration_ms 34.02 recent_avg_logical_reads 1223 recent_exec_count 302 baseline_avg_cpu_ms .18 baseline_avg_duration_ms .53 baseline_avg_logical_reads 8 baseline_exec_count 301 cpu_regression_factor 115.43 duration_regression_factor 63.96 total_plan_count 2 regression_status CRITICAL - CPU 115x baseline; likely plan regression or stats change

CRITICAL, 115 times the baseline CPU, and total_plan_count 2. The recent averages are a blend of the good and bad executions, so the real damage per call is worse than 115x, which the next query shows. Take the query_id and ask Query Store which plan ran when:

SELECT p.plan_id,
       CONVERT(char(16), MIN(ri.start_time), 120) AS first_seen_utc,
       CONVERT(char(16), MAX(ri.end_time), 120) AS last_seen_utc,
       SUM(rs.count_executions) AS execs,
       CAST(AVG(rs.avg_logical_io_reads) AS int) AS avg_reads,
       CAST(AVG(rs.avg_duration) / 1000.0 AS decimal(9,2)) AS avg_ms
FROM sys.query_store_query AS q
JOIN sys.query_store_plan AS p ON p.query_id = q.query_id
JOIN sys.query_store_runtime_stats AS rs ON rs.plan_id = p.plan_id
JOIN sys.query_store_runtime_stats_interval AS ri
    ON ri.runtime_stats_interval_id = rs.runtime_stats_interval_id
WHERE q.object_id = OBJECT_ID('dbo.usp_CustomerOrderSummary')
GROUP BY p.plan_id
ORDER BY first_seen_utc;
The plan timeline on the lab instancereal example output, not something to copy
plan_id first_seen_utc last_seen_utc execs avg_reads avg_ms ------- ---------------- ---------------- ----- --------- ------ 9 2026-10-09 11:16 2026-10-09 12:25 552 8 7.27 14 2026-10-09 12:21 2026-10-09 12:22 51 6083 114.07

That is the incident in two rows. Plan 9 had run since 11:16 at 8 reads a call. Plan 14 appeared at 12:21 and read 6,083 pages a call, 760 times the work, and took 114 ms against 7. Swap q.object_id = OBJECT_ID(...) for q.query_id = the id from the script if the query is ad hoc rather than inside a procedure.

Plan 14 lasted 51 executions. The lab instance is short of memory and evicts plans constantly, so the next compile came from a small customer and plan 9 came back on its own. In production that is the incident that “fixed itself by lunchtime” and comes back next week. One plan with a flat runtime means the plan did not change, and the answer is elsewhere: waits, blocking or a resource, or more rows than yesterday through the same plan.


STEP3

Step 3: Find What Threw the Old Plan Away

A plan does not change on its own. Something invalidated it or pushed it out of cache, and the time Query Store gives you for the new plan is when it happened. Line it up against the usual suspects:

Statistics updated. The one reproduced here. An update with rows modified since the last one marks every plan using those statistics for recompile. Get Statistics Health has last_updated per statistic, or ask directly:

SELECT s.name, sp.last_updated, sp.rows, sp.modification_counter
FROM sys.stats AS s
CROSS APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) AS sp
WHERE s.object_id = OBJECT_ID('dbo.Orders');
Statistics on the lab table after the overnight jobreal example output, not something to copy
name last_updated rows modification_counter ---------------------------- --------------------------- ------ -------------------- PK__Orders__C3905BCF61525632 2026-10-09 13:21:04.2200000 206000 0 IX_Orders_CustomerId 2026-10-09 13:21:04.4533333 206000 0

13:21:04, and plan 14 was first compiled at 12:21:04. That is the same second. The hour between them is the trap: Query Store stores its times in UTC, and last_updated is in the server’s local time, which on the lab is British Summer Time, an hour ahead. Convert one of them before you decide the two are unrelated.

The other triggers, documented rather than reproduced here:

  • Index maintenance. A rebuild updates that index’s statistics with a full scan, so a nightly rebuild job is a statistics job as far as plans are concerned. Check when it ran.
  • A restart, failover or memory pressure. A restart or failover empties the whole plan cache, memory pressure evicts plans from it, and each query that lost its plan recompiles with the first value it sees. The SQL Server error log has the restart or the failover, and on a busy instance the plan cache has no history before the event.
  • A schema change nobody called a deployment. An index dropped or added, a column altered. Get Schema Change History reads the default trace for exactly this.
  • A patch or a compatibility level change. A new CU can change optimiser behaviour, and a compatibility level change can switch the cardinality estimator and other optimiser behaviour with it. Get Patch Level confirms the build, and Compatibility Levels: What Actually Changes covers the other one.

STEP4

Step 4: Find Which Value the New Plan Was Built For

The trigger explains why there was a recompile. It does not explain why the new plan is bad. That part is usually the parameter value that happened to arrive first, and the estimated row counts inside the two plans give it away:

Operators from the two plans in sys.query_store_plan, Compute Scalar and Stream Aggregate rows left outreal example output, not something to copy
plan_id op est_rows idx ------- -------------------- -------- ------------------------------ 9 Nested Loops 2.0 [IX_Orders_CustomerId] 9 Index Seek 2.0 [IX_Orders_CustomerId] 9 Clustered Index Seek 1.0 [PK__Orders__C3905BCF61525632] 14 Clustered Index Scan 106000.0 [PK__Orders__C3905BCF61525632]

Plan 9 was built for a customer with 2 rows: seek the index, look up 2 rows. Plan 14 was built for customer 1 and 106,000 rows: scan the whole table, which is the right plan for that customer and a terrible one for the 300 small customers who followed. Neither plan is wrong. The damage is reuse. Open both in SSMS from the Regressed Queries report or with SELECT CAST(query_plan AS xml) FROM sys.query_store_plan WHERE plan_id = 14;, and the parameter list on the root operator shows the compiled value.

The mechanism and its measured fixes are in Parameter Sniffing in SQL Server. If the recompile picked a plan that suits nobody, rather than one customer, go back to the statistics: a stale or low sample histogram produces a bad estimate for every value, and that fixes with an update, not a hint.


STEP5

Step 5: Put the Good Plan Back, Then Fix It Properly

Query Store will pin the old plan to the query and keep it through recompiles, restarts and failovers. It is the fastest safe way to end the incident, and SQL Server had already worked out the same answer. Less than a second after plan 14 compiled, this appeared in the lab database:

SELECT name, type, reason, valid_since, state, details
FROM sys.dm_db_tuning_recommendations;
sys.dm_db_tuning_recommendations on the lab instance, details trimmedreal example output, not something to copy
name PR_9 type FORCE_LAST_GOOD_PLAN reason Average query CPU time changed from 0.18ms to 58.77ms valid_since 2026-10-09 12:21:05.0466667 state {"currentValue":"Active","reason":"AutomaticTuningOptionNotEnabled"} details ..."regressedPlanId":14,"regressedPlanExecutionCount":6,... "recommendedPlanId":9,"recommendedPlanExecutionCount":301,... "script":"exec sp_query_store_force_plan @query_id = 9, @plan_id = 9"

That is automatic plan correction detecting the regression from 6 executions and writing the fix, then not applying it because FORCE_LAST_GOOD_PLAN is not switched on for the database. Worth knowing it is there: on SQL Server 2017 and later, with Query Store on, this view may already hold your answer. Forcing it by hand:

EXEC sys.sp_query_store_force_plan @query_id = 9, @plan_id = 9;

SELECT p.plan_id, p.is_forced_plan, p.last_force_failure_reason_desc
FROM sys.query_store_plan AS p
WHERE p.query_id = 9
ORDER BY p.plan_id;
After forcing plan 9, lab instancereal example output, not something to copy
plan_id is_forced_plan last_force_failure_reason_desc ------- -------------- ------------------------------ 9 1 NONE 14 0 NONE 27 0 NONE

The recommendation’s state flipped to match:

sys.dm_db_tuning_recommendations after the manual forcereal example output, not something to copy
state {"currentValue":"Success","reason":"PlanForcedByUser"}

Plan 27 is not a mistake. After the force, the procedure was recompiled and run for customer 1, then customer 42. Query Store logged those runs as a new plan 27, which has plan 9’s seek shape but was compiled with customer 1’s estimate of 106,000 rows, so its hash differs. The force is still recorded on plan 9 and the shape held. Customer 42 read 9 pages, back to normal.

Now the cost. Customer 1, the big account, on the forced seek plan: 325,820 logical reads, against 6,083 on its own scan plan. Forcing the last good plan made that call more than 50 times worse to make everyone else fast again. On this data no single plan suits both, and forcing plan 9 is exactly what automatic correction would have done on your behalf. That is the honest case for treating a force as a splint:

  • Write down the query, the plan and the date, and check the force is still holding with Get Query Store Forced Plans. If the forced plan can no longer be built, say because its index was dropped, the force fails silently: the query compiles a plan of its own with no error to the caller, and only force_failure_count and last_force_failure_reason_desc in sys.query_store_plan record it.
  • Fix the query for both kinds of caller. OPTION (RECOMPILE) on the statement, OPTIMIZE FOR a value you choose, or separate code paths for the big account. Each is measured in the parameter sniffing post and in the fixes table on the SSMS vs application page.
  • Then unforce it with sp_query_store_unforce_plan, so the next schema change can produce a better plan.

MS Docs: Automatic tuning, sys.dm_db_tuning_recommendations, sp_query_store_force_plan and Query Store usage scenarios.


If Query Store Is Off

Then there is no yesterday to compare with, and that is the real finding. The plan cache only holds the current plan: sys.dm_exec_query_stats has a creation_time for when it was compiled and a plan_generation_num that rises with each recompile, which tells you the plan is new but not what it replaced. Documented, not reproduced here. Get Top CPU Queries ranks what the cache holds now, so you can at least name the query.

Then turn Query Store on before the next one. How to Check if Query Store Is Enabled takes ten seconds, and Get Query Store Status checks every database on the instance, including the ones that switched themselves to read-only when they filled up. On SQL Server 2022 and later it is on by default for new databases.


Frequently Asked Questions

Why would a query get slower overnight when nothing was deployed?
Because the plan is not part of the deployment. It is rebuilt whenever its statistics change with modified rows, an index is rebuilt, the instance restarts or fails over, or memory pressure pushes it out of cache. The new compile uses whatever parameter value arrives first, so a plan that served you for months can be replaced by one built for an unusual caller. On the lab, inserting 6,000 rows and running the statistics job was enough to swap an 8 read plan for a 6,083 read one. Step 2 proves it with dates.
Should I clear the plan cache or run sp_recompile?
It might work, and it is a coin toss. A recompile builds a new plan from whichever value comes next, which is how the lab got the good plan back after 51 bad executions, and also how it lost it in the first place. DBCC FREEPROCCACHE with no arguments throws away every plan on the instance and makes everything recompile at once. If you must, recompile the one object, but forcing the known good plan is the version that does not depend on luck.
Should I turn on automatic plan correction (FORCE_LAST_GOOD_PLAN)?
Read what it would have done first. On the lab it raised the recommendation in under a second, from 6 executions, and applying it would have made the big account’s call more than 50 times worse, because the last good plan was only good for the small customers. Look at sys.dm_db_tuning_recommendations on your own databases for a while before you let it act, and know it can only choose between plans Query Store has already seen. Automatic tuning on MS Docs covers how it verifies and reverts a force.
The regressions script returns nothing, but the query is definitely slower.
Four reasons, in order of likelihood. Query Store is off in that database, and the script says so in its output, or it went read-only when it filled up, so nothing recent was captured. The query ran fewer than 5 times in either window, which is the @min_executions floor. The slowdown started more than 24 hours ago, so the bad plan is now part of the baseline: raise @recent_hours. Or the plan did not change at all and the query is waiting rather than working, which is Step 1.
The Query Store times and the statistics times are an hour apart. Are they related?
Probably the same moment. Query Store records its times in UTC, as datetimeoffset with +00:00, while STATS_DATE and last_updated are in the server’s local time. On the lab the statistics update at 13:21:04 local and the new plan at 12:21:04 UTC were the same second. Convert before you rule a cause out.
Is this just parameter sniffing?
Parameter sniffing is the reason the new plan is bad. The overnight event is the reason there was a new plan at all. You need both halves: fix only the trigger and the next restart brings it back, fix only the sniffing and you have not explained the timing to anyone. Parameter Sniffing in SQL Server covers the mechanism with its own measurements.

Where To Go Next

Where this investigation usually leads next:

Comments

Leave a Reply

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