"Expensive queries" is a phrase that hides two different problems. One is response time: a statement the application waits too long for. The other is resource efficiency: a statement that burns CPU, logical reads and tempdb out of proportion to the rows it returns, whether or not anyone is waiting on it. On SQL Server 2025 the second kind is the one that quietly sets your core count, and the first kind is the one that generates the ticket. They need different evidence, and often different fixes, and the mistake we see most is tuning one while measuring the other.
This post is the method we use for expensive queries on SQL Server 2025 (17.x, CU8 at the time of writing, database compatibility level 170) to find them both ways, read what the plan is really doing, and fix them with the index or the rewrite the evidence points at. Where SQL Server 2022 behaves differently we say so inline. Every query here runs against Query Store or the DMVs and is safe to run on a production instance; the sample outputs are illustrative and labelled as such, because the numbers that matter are yours.
Expensive queries are two problems, and the axis you pick decides the fix
For expensive queries, response time is what the user experiences: duration, including every wait the session accumulated while blocked, while waiting for a memory grant, or while the client drained a large result set. Resource efficiency is what the engine spent: CPU time, logical reads, tempdb pages and worker threads, per execution and per row returned. A statement can be slow and cheap, when it spends its life in LCK_M_X or ASYNC_NETWORK_IO. A statement can be fast and ruinously expensive, when a two-millisecond lookup runs five thousand times a second with two hundred logical reads per call.
Figure 1. Expensive queries on two axes. The top-right quadrant gets all the attention; the bottom-right quadrant is where the licence cores go.
The reason the distinction matters for expensive queries in practice is that the fixes do not overlap much. Waits are fixed with concurrency design, isolation level, batch sizing and the application's fetch pattern. Resource cost is fixed with plan shape: predicates, statistics, indexes and, occasionally, a hint. If you rank by duration and the top entry is a query blocked behind a long transaction, adding an index to it changes nothing except the maintenance cost of every insert on that table.
Query Store is the evidence chain, and on SQL Server 2025 it is on everywhere
Query Store has been the right place to find expensive queries since SQL Server 2016, but two things changed in 2025 that make it the only place we look first. It is enabled by default for new databases, and it now runs on readable secondaries by default, so an Always On read-scale workload is no longer invisible. The runtime statistics are aggregated per plan per interval, which is exactly the granularity that separates response time from resource cost: duration, CPU, logical reads, rowcount, tempdb and DOP all sit in the same row.
Figure 2. The evidence chain for expensive queries. Every ranking resolves to a query_id and a plan_id, and those two numbers are what you act on.
Before ranking expensive queries, confirm Query Store is capturing what you need. QUERY_CAPTURE_MODE of AUTO skips trivial and infrequent statements, which is fine for finding expensive queries and wrong for auditing everything. Check the operation mode, the interval length and how much of the allocated space is used, because a Query Store that has flipped to READ_ONLY under space pressure is silently stale.
SELECT actual_state_desc,
desired_state_desc,
readonly_reason,
current_storage_size_mb,
max_storage_size_mb,
interval_length_minutes,
query_capture_mode_desc,
stale_query_threshold_days,
wait_stats_capture_mode_desc
FROM sys.database_query_store_options;
-- If actual_state_desc is READ_ONLY with readonly_reason 65536, storage is full:
-- ALTER DATABASE CURRENT SET QUERY_STORE (MAX_STORAGE_SIZE_MB = 2048); -- online, no restart
Then rank the expensive queries. The query below is the one we keep in a snippet. It aggregates the last N hours of runtime intervals per query and plan and computes the four numbers the quadrant diagram asks for: total duration weighted by executions, total CPU, logical reads per row returned, and the coefficient of variation of duration. Sort by whichever question you are answering. The ORDER BY is the whole point of the query, so we leave all four options in and comment out three.
DECLARE @hours INT = 24;
WITH rs AS (
SELECT rs.plan_id,
SUM(rs.count_executions) AS executions,
SUM(rs.count_executions * rs.avg_duration) / 1000.0 AS total_duration_ms,
SUM(rs.count_executions * rs.avg_cpu_time) / 1000.0 AS total_cpu_ms,
SUM(rs.count_executions * rs.avg_logical_io_reads) AS total_logical_reads,
SUM(rs.count_executions * rs.avg_rowcount) AS total_rows,
SUM(rs.count_executions * rs.avg_tempdb_space_used) AS total_tempdb_pages,
MAX(rs.max_duration) / 1000.0 AS max_duration_ms,
MAX(rs.stdev_duration) / 1000.0 AS max_stdev_duration_ms,
MAX(rs.max_dop) AS max_dop
FROM sys.query_store_runtime_stats AS rs
JOIN sys.query_store_runtime_stats_interval AS i
ON i.runtime_stats_interval_id = rs.runtime_stats_interval_id
WHERE i.start_time >= DATEADD(HOUR, -@hours, SYSUTCDATETIME())
GROUP BY rs.plan_id
)
SELECT TOP (25)
q.query_id,
p.plan_id,
p.is_forced_plan,
p.compatibility_level,
rs.executions,
CAST(rs.total_duration_ms / NULLIF(rs.executions, 0) AS DECIMAL(18, 2)) AS avg_duration_ms,
CAST(rs.max_duration_ms AS DECIMAL(18, 2)) AS max_duration_ms,
CAST(rs.total_cpu_ms AS DECIMAL(18, 2)) AS total_cpu_ms,
CAST(rs.total_logical_reads / NULLIF(rs.total_rows, 0) AS DECIMAL(18, 1)) AS reads_per_row,
CAST(rs.max_stdev_duration_ms / NULLIF(rs.total_duration_ms / NULLIF(rs.executions, 0), 0) AS DECIMAL(9, 3)) AS duration_cv,
rs.total_tempdb_pages,
rs.max_dop,
LEFT(qt.query_sql_text, 160) AS query_text
FROM rs
JOIN sys.query_store_plan AS p ON p.plan_id = rs.plan_id
JOIN sys.query_store_query AS q ON q.query_id = p.query_id
JOIN sys.query_store_query_text AS qt ON qt.query_text_id = q.query_text_id
ORDER BY rs.total_duration_ms DESC; -- response time, weighted by how often it runs
-- ORDER BY rs.total_cpu_ms DESC; -- resource cost: the core-count question
-- ORDER BY reads_per_row DESC; -- efficiency: reads per row returned
-- ORDER BY duration_cv DESC; -- variance: parameter sensitivity and plan flips
Two of those columns do more work for expensive queries than the rest. reads_per_row is the efficiency ratio: a query returning ten rows at 40,000 logical reads per execution is doing 4,000 reads for each row it hands back, and no amount of hardware makes that reasonable. duration_cv, the standard deviation of duration divided by the mean, is how you find parameter-sensitive expensive queries before a user does; a value above 1 on a frequently executed statement almost always means more than one plan shape is being used for the same query_id.
The output below is illustrative, not from a customer system, and shows the shape we are looking for: one query high on every axis, one high on CPU only, and one whose variance gives it away.
query_id plan_id executions avg_duration_ms max_duration_ms total_cpu_ms reads_per_row duration_cv max_dop -------- ------- ---------- --------------- --------------- ------------ ------------- ----------- ------- 40217 9912 18240 412.8 8391.2 6204812.1 3892.4 1.812 8 11203 2210 1928400 1.9 24.7 3517110.6 206.0 0.220 1 40391 10044 2210 1290.5 2011.0 81023.9 12.3 0.140 4 ...
Among those three expensive queries, query_id 40217 is the classic top-right quadrant: slow, expensive and unstable. query_id 11203 is the bottom-right one, two milliseconds a call and the second-largest CPU consumer on the instance because it runs 1.9 million times a day with 206 reads per row. Sorting by average duration would never have shown it. query_id 40391 is slow and steady with a modest read ratio, which usually means it is waiting rather than working, and the wait stats view settles that.
SELECT ws.plan_id,
ws.wait_category_desc,
SUM(ws.total_query_wait_time_ms) AS total_wait_ms,
MAX(ws.max_query_wait_time_ms) AS max_wait_ms
FROM sys.query_store_wait_stats AS ws
JOIN sys.query_store_runtime_stats_interval AS i
ON i.runtime_stats_interval_id = ws.runtime_stats_interval_id
WHERE ws.plan_id IN (9912, 2210, 10044)
AND i.start_time >= DATEADD(HOUR, -24, SYSUTCDATETIME())
GROUP BY ws.plan_id, ws.wait_category_desc
ORDER BY ws.plan_id, total_wait_ms DESC;
If an expensive query's duration is mostly Lock, Network IO or Memory waits, it belongs in the slow-but-cheap quadrant and the index conversation is over before it starts. On SQL Server 2025 there is a wait category worth new attention: the LCK_M_*_XACT waits that appear once optimized locking is enabled, where sessions wait on the transaction resource rather than on individual rows. Baseline lock waits before turning that feature on, or you will not be able to tell whether it helped.
Expensive queries from the plan cache DMVs: still useful for one thing
sys.dm_exec_query_stats still has a place, mainly on instances where Query Store is off or on ad hoc workloads where it captures little. Its numbers reset on plan eviction and restart, it has no interval history, and on a busy instance it undercounts anything that recompiles often. We use it for one thing Query Store does not do well: finding expensive queries by the memory grant they requested, which is the fastest route to the RESOURCE_SEMAPHORE waits that stall everything else.
SELECT TOP (20)
qs.execution_count,
qs.max_grant_kb,
qs.max_used_grant_kb,
qs.max_ideal_grant_kb,
qs.max_spills,
qs.total_worker_time / 1000 / NULLIF(qs.execution_count, 0) AS avg_cpu_ms,
qs.total_logical_reads / NULLIF(qs.execution_count, 0) AS avg_logical_reads,
qs.query_hash,
SUBSTRING(st.text, (qs.statement_start_offset / 2) + 1,
((CASE qs.statement_end_offset WHEN -1 THEN DATALENGTH(st.text)
ELSE qs.statement_end_offset END - qs.statement_start_offset) / 2) + 1) AS statement_text
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
WHERE qs.max_grant_kb > 65536
ORDER BY qs.max_grant_kb DESC;
A max_used_grant_kb far below max_grant_kb is an over-estimate that starves other sessions; a max_spills above zero with used close to granted is an under-estimate that spilled. Both come back to cardinality, which is where reading the plans of expensive queries starts.
Reading the plan of an expensive query without flattering it
The actual execution plan is where a ranking of expensive queries becomes a diagnosis. Estimated plans are useful for review; they are useless for troubleshooting, because the whole problem is usually that the estimate was wrong. Query Store keeps the plan XML per plan_id, and on SQL Server 2019 and later the LAST_QUERY_PLAN_STATS database-scoped configuration keeps the last actual plan with runtime counters for cached statements, which is the cheapest way to get an actual plan for something you cannot re-run at will.
Figure 3. Five plan shapes that account for most expensive queries, and the number in the plan that exposes each.
Rather than opening every one of the expensive queries in a plan viewer, we pull the warnings and the operator inventory out of the XML for the top plan_ids in one pass. The query below flags the four things we want to know before we look at anything else: is there a key lookup, a spill, an implicit conversion, or a scan on a table the predicate should have seeked.
;WITH XMLNAMESPACES (DEFAULT 'http://schemas.microsoft.com/sqlserver/2004/07/showplan')
SELECT p.plan_id,
p.query_id,
x.value('count(//IndexScan[@Lookup="true"])', 'INT') AS key_lookups,
x.value('count(//RelOp[@PhysicalOp="Clustered Index Scan" or @PhysicalOp="Index Scan" or @PhysicalOp="Table Scan"])', 'INT')
AS scans,
x.value('count(//Warnings/SpillToTempDb)', 'INT') AS spills,
x.value('count(//Warnings/PlanAffectingConvert[@ConvertIssue="Seek Plan"])', 'INT')
AS implicit_conversions,
x.value('count(//RelOp[@PhysicalOp="Table Spool" or @PhysicalOp="Index Spool"])', 'INT')
AS spools,
x.value('(//StmtSimple/@StatementOptmEarlyAbortReason)[1]', 'NVARCHAR(50)') AS optimizer_abort_reason,
x.value('(//QueryPlan/@CachedPlanSize)[1]', 'INT') AS cached_plan_kb
FROM sys.query_store_plan AS p
CROSS APPLY (SELECT TRY_CONVERT(XML, p.query_plan)) AS c(x)
WHERE p.plan_id IN (9912, 2210, 10044)
ORDER BY p.plan_id;
For the expensive queries that survive triage, the operator-level view is where the efficiency ratio lives. Two properties on every operator in an actual plan tell you what it cost: Number of Rows Read, which is how many rows the operator touched, and Actual Number of Rows, which is how many came out. A seek that reads 900,000 rows to output 40 is a seek in name only; it is a range scan with a residual predicate, and the residual is nearly always a non-SARGable expression or a column that should have been in the key. STATISTICS IO gives the same information per table when you can run the statement yourself.
SET STATISTICS IO, TIME ON; SET STATISTICS XML ON; DECLARE @customer_id INT = 48213, @from DATE = '2026-08-01'; SELECT o.order_id, o.order_date, o.status, ol.sku, ol.qty, ol.line_total FROM dbo.orders AS o JOIN dbo.order_lines AS ol ON ol.order_id = o.order_id WHERE o.customer_id = @customer_id AND CONVERT(DATE, o.order_date) >= @from -- non-SARGable: function on the column ORDER BY o.order_date DESC; SET STATISTICS XML OFF; SET STATISTICS IO, TIME OFF; -- Illustrative STATISTICS IO line (not a measured result): -- Table 'orders'. Scan count 1, logical reads 184112, ... lob logical reads 0 -- Table 'order_lines'. Scan count 61, logical reads 244, ...
That CONVERT on order_date is the single most common reason an otherwise well-indexed statement becomes one of the expensive queries on an instance. The optimizer cannot seek on a function of the column, so it scans the whole customer's range, or the whole table, and filters afterwards. The rewrite is a half-open range on the raw column, and the reads collapse to whatever the index range actually covers. The same demotion happens with implicit conversions between NVARCHAR parameters and VARCHAR columns, with ISNULL(col, x) = y, with LIKE '%' + @p, and with arithmetic on the column side of a comparison.
WHERE o.customer_id = @customer_id AND o.order_date >= CAST(@from AS DATETIME2(0)) -- compare the raw column, match its type ORDER BY o.order_date DESC;
What SQL Server 2025 changed for expensive queries, and where 2022 differs
Intelligent Query Processing is the umbrella for the optimizer features that adjust plans from runtime feedback, and SQL Server 2025 extended it in ways that change how expensive queries behave after an upgrade. We pin each item to the version and the compatibility level it needs, because a database restored onto a 2025 instance at compatibility level 150 gets almost none of this.
| Feature | SQL Server 2022 | SQL Server 2025 | What to check |
|---|---|---|---|
| Parameter Sensitive Plan optimization | New in 2022, compat 160; up to three plan variants per parameterised predicate | Same mechanism; variant queries appear as their own query_ids linked to the parent in sys.query_store_query_variant | Join runtime stats through sys.query_store_query_variant; high duration_cv on a query with a single plan means PSP did not engage |
| Optional Parameter Plan Optimization (OPPO) | Not available | Multiple plans per statement chosen on which optional parameters are NULL; the answer to the "WHERE (@a IS NULL OR col = @a)" pattern | Compat 170; on by default via the OPTIONAL_PARAMETER_OPTIMIZATION database-scoped configuration; DISABLE_OPTIONAL_PARAMETER_OPTIMIZATION hint per query |
| Cardinality estimation feedback | Correlation, join containment and row goal scenarios; compat 160, Query Store required | Extended to expressions; CE_FEEDBACK database-scoped configuration and the DISABLE_CE_FEEDBACK hint | Build number: a CE feedback defect in an early CU caused plan cache growth and CPU; confirm the fix is in your CU before relying on it |
| DOP feedback | Opt-in via DOP_FEEDBACK | On by default; parallel expensive queries that gained nothing from parallelism get their DOP reduced over successive executions | max_dop in runtime_stats dropping across intervals for the same plan_id is feedback working, not a regression |
| Query Store on readable secondaries | Preview, off by default | On by default, with persisted statistics on secondaries | Rank expensive queries on the reporting replica too; the secondary no longer loses its stats on failover |
| Query Store hint ABORT_QUERY_EXECUTION | Not available | Block a query shape at compile time without touching application code; raises error 8778 to the caller | A circuit breaker for the runaway report at 02:00, reversible with sp_query_store_clear_hints |
| Optimized locking | Not available on-premises | Opt-in per database; requires ADR, needs RCSI for lock-after-qualification; new LCK_M_*_XACT waits | Changes the wait profile of slow-but-cheap expensive queries; review write-ordering assumptions before enabling |
The compatibility-level point deserves its own sentence. Microsoft's rule is that cardinality estimator changes only activate at the default compatibility level of the version that introduced them, so a database left at 150 after a 2025 upgrade keeps its old plans and gains none of the feedback mechanisms above, which is sometimes exactly what you want for the first weeks and is never what you want permanently. Microsoft's What's new in SQL Server 2025 page is the reference for the table, and the Intelligent Query Processing details page carries the per-feature compatibility requirements.
Indexing expensive queries: the decision, then the proof
Only after the predicate is SARGable and the statistics are current does an index change for expensive queries make sense; an index built around a bad predicate fossilises the bad predicate. The design sequence we follow is short and we follow it in order every time, because the order is what decides whether the index seeks or scans.
Figure 4. Indexing expensive queries: key design from the predicate, cost checks from the DMVs, proof before CREATE INDEX reaches production.
The missing index DMVs are a starting point for expensive queries and nothing more. They propose one index per query shape, never consolidate, over-include, and ignore the write side entirely. We read them together with the usage stats, so every proposal is weighed against what the table already carries and how hard it is written.
SELECT OBJECT_SCHEMA_NAME(mid.object_id) + '.' + OBJECT_NAME(mid.object_id) AS table_name,
mid.equality_columns,
mid.inequality_columns,
mid.included_columns,
migs.user_seeks + migs.user_scans AS would_have_used,
CAST(migs.avg_total_user_cost * migs.avg_user_impact / 100.0
* (migs.user_seeks + migs.user_scans) AS DECIMAL(18, 1)) AS improvement_measure,
(SELECT COUNT(*) FROM sys.indexes i WHERE i.object_id = mid.object_id AND i.index_id > 0) AS existing_indexes,
(SELECT SUM(us.user_updates) FROM sys.dm_db_index_usage_stats us
WHERE us.object_id = mid.object_id AND us.database_id = DB_ID()) AS index_writes_since_restart
FROM sys.dm_db_missing_index_details AS mid
JOIN sys.dm_db_missing_index_groups AS mig ON mig.index_handle = mid.index_handle
JOIN sys.dm_db_missing_index_group_stats AS migs ON migs.group_handle = mig.index_group_handle
WHERE mid.database_id = DB_ID()
ORDER BY improvement_measure DESC;
SELECT OBJECT_SCHEMA_NAME(i.object_id) + '.' + OBJECT_NAME(i.object_id) AS table_name,
i.name AS index_name,
i.type_desc,
i.is_unique,
i.has_filter,
us.user_seeks, us.user_scans, us.user_lookups, us.user_updates,
us.last_user_seek, us.last_user_scan,
ps.used_page_count * 8 / 1024 AS size_mb
FROM sys.indexes AS i
LEFT JOIN sys.dm_db_index_usage_stats AS us
ON us.object_id = i.object_id AND us.index_id = i.index_id AND us.database_id = DB_ID()
JOIN sys.dm_db_partition_stats AS ps
ON ps.object_id = i.object_id AND ps.index_id = i.index_id
WHERE i.index_id > 1 -- nonclustered only
AND i.is_primary_key = 0
AND i.is_unique_constraint = 0
AND OBJECTPROPERTY(i.object_id, 'IsUserTable') = 1
ORDER BY ISNULL(us.user_seeks, 0) + ISNULL(us.user_scans, 0) + ISNULL(us.user_lookups, 0) ASC,
us.user_updates DESC;
Index usage stats reset on restart, so read them against the instance uptime from sys.dm_os_sys_info, and never drop an index on the strength of a counter that has only seen a fortnight; the quarter-end report that uses it is real. When we do add an index for one of the expensive queries, the DDL follows the design sequence exactly, and it goes in online and resumable so the operation can be paused if it starts hurting.
CREATE NONCLUSTERED INDEX ix_orders_customer_date
ON dbo.orders (customer_id, order_date DESC)
INCLUDE (status)
WITH (ONLINE = ON, RESUMABLE = ON, MAX_DURATION = 60 MINUTES,
SORT_IN_TEMPDB = ON, DATA_COMPRESSION = PAGE);
-- Pause and resume without losing progress if the window closes:
-- ALTER INDEX ix_orders_customer_date ON dbo.orders PAUSE;
-- ALTER INDEX ix_orders_customer_date ON dbo.orders RESUME;
-- Filtered variant when only open orders are ever queried this way:
CREATE NONCLUSTERED INDEX ix_orders_customer_date_open
ON dbo.orders (customer_id, order_date DESC)
INCLUDE (status)
WHERE status IN ('NEW', 'PICKING', 'PACKED')
WITH (ONLINE = ON, DATA_COMPRESSION = PAGE);
Three notes on that DDL. The descending key on order_date lets the ORDER BY come straight off the index without a sort, which matters more than it looks on a statement that runs thousands of times an hour.
The filtered variant is smaller and cheaper to maintain, but it only serves queries whose predicate the optimizer can prove is contained in the filter, and a parameterised status predicate usually cannot be proved, so we check the plan rather than assume. And if the expensive query is an aggregation over millions of rows rather than a lookup of dozens, no rowstore key fixes it; a nonclustered columnstore index on the same table, with batch mode, is the right tool and does not interfere with the OLTP path.
The fix ladder: reversible first, permanent second
Figure 5. The loop for expensive queries: one change per iteration, verified against the same query_id in the next Query Store interval, with the rollback named before the change is made.
What makes SQL Server unusual among the engines we work on is how much of the fix ladder for expensive queries is reversible without a deployment. Query Store hints attach OPTION clauses to a query_id without touching application code; plan forcing pins a known-good plan_id while the real fix is engineered; and on 2025 ABORT_QUERY_EXECUTION stops a query shape outright. All three are undone with one procedure call. We use them as the first move and the index or rewrite as the second, because at 02:00 the reversible fix is the safe one, and because a forced plan buys the time to do the permanent fix properly.
-- 1. A hint without a code change: cap the memory grant and pin the estimator behaviour
EXEC sys.sp_query_store_set_hints
@query_id = 40217,
@query_hints = N'OPTION (MAX_GRANT_PERCENT = 10, USE HINT(''DISABLE_CE_FEEDBACK''))';
-- Verify it took: hint_id and the query_hints text
SELECT query_id, query_hint_text, source_desc FROM sys.query_store_query_hints WHERE query_id = 40217;
-- Undo
EXEC sys.sp_query_store_clear_hints @query_id = 40217;
-- 2. Force the plan that behaved, while the permanent fix is built
EXEC sys.sp_query_store_force_plan @query_id = 40217, @plan_id = 9877;
-- Verify: is_forced_plan = 1 and no force_failure_count growth
SELECT plan_id, is_forced_plan, force_failure_count, last_force_failure_reason_desc
FROM sys.query_store_plan WHERE query_id = 40217;
-- Undo
EXEC sys.sp_query_store_unforce_plan @query_id = 40217, @plan_id = 9877;
-- 3. SQL Server 2025 only: stop a runaway query shape at compile time (circuit breaker, not a fix)
EXEC sys.sp_query_store_set_hints
@query_id = 40217,
@query_hints = N'OPTION (USE HINT(''ABORT_QUERY_EXECUTION''))';
-- Undo with sp_query_store_clear_hints as above
Two cautions from experience with expensive queries and forced plans. A forced plan that references an index you later drop fails silently to force and the query falls back to whatever the optimizer picks; force_failure_count is the only place that shows it, so we alert on it. And hints that disable feedback mechanisms should be dated and reviewed, because the whole reason the mechanism existed was to fix the class of problem the hint is masking.
Verification is the same ranking of expensive queries that found the problem, filtered to the query_id, one interval later. We want to see avg_duration_ms and reads_per_row move in the right direction and duration_cv fall, on the same executions count; if executions also collapsed, someone changed the application and nothing has been measured. We keep the before and after rows in the ticket, because the next time someone asks whether the index was worth its write cost, those two rows are the answer.
SELECT i.start_time,
rs.plan_id,
rs.count_executions,
CAST(rs.avg_duration / 1000.0 AS DECIMAL(18, 2)) AS avg_duration_ms,
CAST(rs.avg_cpu_time / 1000.0 AS DECIMAL(18, 2)) AS avg_cpu_ms,
CAST(rs.avg_logical_io_reads / NULLIF(rs.avg_rowcount, 0) AS DECIMAL(18, 1)) AS reads_per_row,
CAST(rs.stdev_duration / NULLIF(rs.avg_duration, 0) AS DECIMAL(9, 3)) AS duration_cv,
rs.max_dop
FROM sys.query_store_runtime_stats AS rs
JOIN sys.query_store_runtime_stats_interval AS i ON i.runtime_stats_interval_id = rs.runtime_stats_interval_id
JOIN sys.query_store_plan AS p ON p.plan_id = rs.plan_id
WHERE p.query_id = 40217
AND i.start_time >= DATEADD(DAY, -3, SYSUTCDATETIME())
ORDER BY i.start_time, rs.plan_id;
Expensive queries: fixes that did not help, and the ones we no longer do
Adding MAXDOP hints to expensive queries that were slow because of a spill did nothing except make them serial and slower. Rebuilding indexes to fix an expensive query has, in our experience, fixed the query exactly as often as the rebuild happened to refresh statistics that were the actual problem, which is why we now update statistics with FULLSCAN first and rebuild only for fragmentation that the plan shows is hurting.
Turning off parameter sniffing instance-wide traded one set of expensive queries for another and made every plan mediocre; PSP optimization and OPPO exist precisely so that trade is no longer necessary on 2022 and 2025. And clearing the plan cache "to see if it helps" is a way of losing the evidence you needed while creating a compilation storm.
Where MinervaDB fits
MinervaDB delivers SQL Server consulting, 24x7 consultative support and remote DBA services for on-premises, Always On and Azure SQL estates. Work on expensive queries is a standing part of it: Query Store based performance health checks, 2019 and 2022 to 2025 upgrade assessments that stage the compatibility level jump behind Query Store and review optimized locking before it is enabled, and index estate reviews that remove the dead weight before adding anything. We work alongside your DBAs and your Microsoft support agreement rather than in place of either.
Test every query, hint, forced plan and index in this post on a non-production copy of your database with a representative workload before you apply it to production, keep backups and a tested restore path current so that every change has a rollback, and maintain your disaster recovery posture throughout. The method for expensive queries holds across workloads. Which of these fixes pays on yours is something only your Query Store can tell you.