LyraLearn AI Learning Platform
Exams
← Module 1 Β· Measure First: Finding What's Actually Slow

Query Store: Your First Stop

Query Store is a flight recorder built into the database. It captures query text, every plan the optimizer chose, and per-interval runtime statistics β€” duration, CPU, reads, memory grants, executions. Unlike the plan cache, it survives restarts, evictions, and failovers.

In SQL Server 2022 it is on by default for new databases. On an older database, or one restored from an older instance, it may be off. Check first:

SELECT  DB_NAME()                AS database_name,
        actual_state_desc,
        readonly_reason,
        current_storage_size_mb,
        max_storage_size_mb,
        query_capture_mode_desc,
        stale_query_threshold_days
FROM sys.database_query_store_options;

actual_state_desc should be READ_WRITE. If it says OFF, or READ_ONLY with a non-zero readonly_reason (usually "storage full"), you are not collecting anything.

Turning it on is a normal ALTER DATABASE change, but it does write to the database β€” get the usual approval before running this on production:

ALTER DATABASE [YourDatabase] SET QUERY_STORE = ON;

ALTER DATABASE [YourDatabase] SET QUERY_STORE (
    OPERATION_MODE            = READ_WRITE,
    QUERY_CAPTURE_MODE        = AUTO,          -- skips trivial one-off queries
    MAX_STORAGE_SIZE_MB       = 2048,
    DATA_FLUSH_INTERVAL_SECONDS = 900,
    INTERVAL_LENGTH_MINUTES   = 60,
    SIZE_BASED_CLEANUP_MODE   = AUTO
);

Once it is on, you have to wait for the workload to run before it tells you anything. That is another reason to turn it on early rather than at the moment of crisis.

Top resource consumers

The SSMS built-in reports (Object Explorer β†’ your database β†’ Query Store) are genuinely good, and "Top Resource Consuming Queries" is where to start. But knowing the underlying query means you can run it anywhere, filter it, and paste it into a ticket.

-- Top 25 queries by total duration over the last 24 hours
SELECT TOP (25)
       qsq.query_id,
       qsp.plan_id,
       SUBSTRING(qst.query_sql_text, 1, 300)        AS query_snippet,
       SUM(rs.count_executions)                      AS executions,
       SUM(rs.avg_duration * rs.count_executions)/1000.0     AS total_duration_ms,
       AVG(rs.avg_duration)/1000.0                   AS avg_duration_ms,
       AVG(rs.avg_cpu_time)/1000.0                   AS avg_cpu_ms,
       AVG(rs.avg_logical_io_reads)                  AS avg_logical_reads,
       AVG(rs.avg_query_max_used_memory)             AS avg_memory_grant_pages
FROM sys.query_store_runtime_stats     AS rs
JOIN sys.query_store_runtime_stats_interval AS rsi ON rsi.runtime_stats_interval_id = rs.runtime_stats_interval_id
JOIN sys.query_store_plan              AS qsp ON qsp.plan_id  = rs.plan_id
JOIN sys.query_store_query             AS qsq ON qsq.query_id = qsp.query_id
JOIN sys.query_store_query_text        AS qst ON qst.query_text_id = qsq.query_text_id
WHERE rsi.start_time >= DATEADD(HOUR, -24, SYSDATETIMEOFFSET())
GROUP BY qsq.query_id, qsp.plan_id, SUBSTRING(qst.query_sql_text, 1, 300)
ORDER BY total_duration_ms DESC;

Sort by total duration first, not average. A query taking 200 ms that runs 400,000 times costs the server more than your 40-second report, and if you are hunting general slowness that is where the win is. When you are hunting one specific slow report, sort by average and search the text.

Finding your report's query

You rarely know the query_id up front. Search by a distinctive fragment of the SQL β€” a table name, a column, a comment:

SELECT  qsq.query_id,
        qsp.plan_id,
        qsp.is_forced_plan,
        qst.query_sql_text
FROM sys.query_store_query_text AS qst
JOIN sys.query_store_query      AS qsq ON qsq.query_text_id = qst.query_text_id
JOIN sys.query_store_plan       AS qsp ON qsp.query_id = qsq.query_id
WHERE qst.query_sql_text LIKE '%FactSalesReport%'    -- placeholder: a table or proc your report hits
ORDER BY qsq.query_id, qsp.plan_id;

If one query_id has several plan_id values, note that. It means the optimizer has produced more than one plan for the same text, which is the fingerprint of parameter sniffing (Module 5).

Regressed queries: same query, new plan, worse

This is the single most useful thing Query Store does, because it answers "what changed?" without anyone having to remember.

-- Queries whose average duration got worse after a plan change
WITH per_plan AS (
    SELECT  qsp.query_id,
            qsp.plan_id,
            MIN(rsi.start_time)          AS first_seen,
            SUM(rs.count_executions)     AS execs,
            SUM(rs.avg_duration * rs.count_executions)
              / NULLIF(SUM(rs.count_executions),0) / 1000.0 AS avg_ms
    FROM sys.query_store_runtime_stats AS rs
    JOIN sys.query_store_runtime_stats_interval AS rsi
         ON rsi.runtime_stats_interval_id = rs.runtime_stats_interval_id
    JOIN sys.query_store_plan AS qsp ON qsp.plan_id = rs.plan_id
    WHERE rsi.start_time >= DATEADD(DAY, -14, SYSDATETIMEOFFSET())
    GROUP BY qsp.query_id, qsp.plan_id
)
SELECT  p.query_id,
        COUNT(*)          AS distinct_plans,
        MIN(p.avg_ms)     AS best_plan_avg_ms,
        MAX(p.avg_ms)     AS worst_plan_avg_ms,
        MAX(p.avg_ms) / NULLIF(MIN(p.avg_ms),0) AS regression_factor
FROM per_plan AS p
GROUP BY p.query_id
HAVING COUNT(*) > 1
   AND MAX(p.avg_ms) / NULLIF(MIN(p.avg_ms),0) > 3    -- at least 3x worse on the bad plan
ORDER BY regression_factor DESC;

A query with two plans, one averaging 300 ms and one averaging 40 seconds, is not a query that needs an index. It is a query that needs the right plan, reliably.

Forcing a plan β€” a real fix, and a temporary one

If the good plan exists in history, you can pin it:

EXEC sp_query_store_force_plan @query_id = 1234, @plan_id = 5678;

-- and to release it later
EXEC sp_query_store_unforce_plan @query_id = 1234, @plan_id = 5678;

This is a legitimate emergency lever: it restores yesterday's performance in seconds without a deployment. It is not a permanent answer, because a forced plan can stop being appropriate as data grows, and forcing can silently fail (check sys.query_store_plan.is_forced_plan and last_force_failure_reason_desc). Use it to buy time while you fix the underlying cause.

Proving the fix

This is the part people skip. Without a before and after, you do not actually know whether you fixed anything. After you deploy a change, come back to the same query_id and compare intervals:

SELECT  CAST(rsi.start_time AS date)  AS day,
        qsp.plan_id,
        SUM(rs.count_executions)      AS execs,
        AVG(rs.avg_duration)/1000.0   AS avg_ms,
        AVG(rs.avg_logical_io_reads)  AS avg_reads
FROM sys.query_store_runtime_stats AS rs
JOIN sys.query_store_runtime_stats_interval AS rsi
     ON rsi.runtime_stats_interval_id = rs.runtime_stats_interval_id
JOIN sys.query_store_plan AS qsp ON qsp.plan_id = rs.plan_id
WHERE qsp.query_id = 1234                      -- placeholder: your query_id
  AND rsi.start_time >= DATEADD(DAY, -14, SYSDATETIMEOFFSET())
GROUP BY CAST(rsi.start_time AS date), qsp.plan_id
ORDER BY day, plan_id;

"Average duration went from 41 seconds to 1.2 seconds and logical reads from 2.1 million to 8,400, measured over a week of real production runs" is an entirely different statement from "it feels faster now."

🧠 Quiz yourself on this lesson →

Ask the AI Tutor

Grounded in the course lessons β€” it cites its sources and says when it doesn't know.