Sign in or create a free account to continue.

MSAZ-281 · Lesson 10 of 12 · 20 min

Query plans and tuning

▶ Video coming soon A video version of this lesson is on the way.

Keiko Tanabe's nightly sales report has a query that takes eleven minutes on the old StockLine server and nineteen on the test Managed Instance. Everyone expected Azure to be faster, so the result causes some alarm in the migration stand-up. Elena brings the query to the clinic instead of to a bigger service tier. "Before we buy more vCores," she says, "we read the plan." This session is about reading execution plans and changing queries and settings so the optimizer can do its job.

Estimated and actual plans

An execution plan is the optimizer's recipe for a query: which indexes to read, in what order to join tables, which algorithms to use for joins and aggregations, whether to sort, and how many threads to use. An estimated plan shows what the optimizer intends, based on statistics, without running the query. An actual plan is captured when the query runs and adds what really happened: actual row counts for each operator, the number of executions, warnings such as spills to tempdb, and runtime statistics. Live query statistics show the plan filling in while a long query runs. Query Store keeps estimated plans for each query over time, and since SQL Server 2019 the last actual plan can also be kept in the plan cache with lightweight profiling.

Plans are read from right to left and top to bottom: data flows from the operators on the right, which read tables and indexes, toward the SELECT on the left. Each arrow's thickness shows how many rows flow along it. The first thing Walt looks for is a big difference between estimated and actual row counts, because that is where the optimizer was surprised, and a surprised optimizer chooses badly.

A simplified execution plan read from right to left. On the right, a clustered index scan of sales.SaleLine with estimated rows 1,200 and actual rows 3,850,000 feeds a nested loops join through a thick arrow. The nested loops join performs 3,850,000 seeks into product.Product. A sort operator labeled spill to tempdb sits before the SELECT. Callouts mark the estimate gap, the nested loops chosen because of the low estimate, and the spill caused by a memory grant sized for 1,200 rows.
A plan where a bad row estimate led to a nested loops join and an undersized memory grant.

What the report query was doing

The plan of Keiko's query shows the pattern in the figure. The query filters sale lines with WHERE CONVERT(date, SaleTime) = @ReportDate. Wrapping the column in a function means the optimizer cannot use the histogram on SaleTime or seek on the index; it scans the whole table and guesses that a fixed small fraction of rows match. It estimates 1,200 rows; 3,850,000 arrive. Because it expected few rows, it chose a nested loops join, which looks up each row in the product table one at a time, and a memory grant sized for 1,200 rows, so the final sort spilled to tempdb. On the old server, a warm buffer pool hid much of the cost; on the new instance, with colder caches during the replay, it did not.

The fix is to make the predicate sargable, meaning searchable by an index: compare the column itself with a range. With WHERE SaleTime >= @ReportDate AND SaleTime < DATEADD(day, 1, @ReportDate), the optimizer can seek on the index on SaleTime and use the histogram. The estimate becomes 3.9 million, the join becomes a hash join, the memory grant fits, and the query finishes in 2 minutes 40 seconds on the test instance.

Rewriting a non-sargable date filter as a range.
-- Before: the function on the column hides it from the index and the histogram
SELECT p.Department, SUM(sl.Quantity * sl.UnitPrice) AS sales
FROM sales.SaleLine AS sl
JOIN product.Product AS p ON p.Sku = sl.Sku
WHERE CONVERT(date, sl.SaleTime) = @ReportDate
GROUP BY p.Department;

-- After: a range on the bare column
SELECT p.Department, SUM(sl.Quantity * sl.UnitPrice) AS sales
FROM sales.SaleLine AS sl
JOIN product.Product AS p ON p.Sku = sl.Sku
WHERE sl.SaleTime >= @ReportDate AND sl.SaleTime < DATEADD(day, 1, @ReportDate)
GROUP BY p.Department;

Query constructs that hurt

DP-300 asks candidates to recommend changes to query constructs based on resource use. A handful of patterns cause most problems. Functions applied to columns in WHERE or JOIN clauses, as above. Implicit conversions, where a column of one type is compared with a parameter of another: a varchar column compared with an nvarchar parameter forces a conversion of every row and shows as a warning on the plan. Patterns in LIKE that start with a wildcard, such as a search for every product name ending in paint, which cannot seek. SELECT * in queries that need three columns, which defeats covering indexes. Scalar user-defined functions called for every row, which older versions could not inline. Row-by-row loops and cursors where one set-based statement would do. And OR conditions across different columns, which can sometimes be rewritten as a UNION of two seeks.

Query patterns that waste resources and their usual replacements.
ConstructWhy it hurtsBetter construct
Function on a column in WHEREPrevents seeks and good estimatesRange or computed column with an index
Implicit conversionConverts every row; may prevent seeksMatch parameter types to column types
Leading wildcard in LIKEForces a scanFull-text search, or store a reversed or split value
SELECT *Reads unneeded columns; defeats covering indexesName the needed columns
Cursor or loop over rowsMany small statements, much overheadOne set-based statement
Scalar function per rowRuns once per row, may block parallelismInline table-valued function or inlining on current versions

Parameter sensitivity

Stored procedures and parameterized queries are compiled once and the plan is reused. The plan is optimized for the parameter values of the first call, which is called parameter sniffing. Usually that is good. It goes wrong when data is skewed: a procedure that lists redemptions for a store compiles first for store 47, a tiny store, and builds a plan with a nested loops join that is ideal for 30 rows; the next call, for store 12 with 5,400 rows, reuses that plan and runs slowly. Hana sees exactly this in the Finch Rewards test database.

There are several remedies. OPTION (RECOMPILE) compiles a fresh plan for each execution, which costs CPU but is fine for queries that run rarely. OPTIMIZE FOR a typical value, or OPTIMIZE FOR UNKNOWN, picks one plan deliberately. Splitting the procedure, so big and small stores take different paths, is clearer. And on SQL Server 2022, Managed Instance and Azure SQL Database at compatibility level 160 or higher, parameter sensitive plan optimization can keep several plans for one query, chosen by the parameter value's size range, automatically.

Intelligent query processing

Recent versions of the engine include a family of features, called intelligent query processing, that improve plans without code changes. They switch on through the database compatibility level, which is one reason to run at the current level after a migration. Adaptive joins decide between a nested loops and a hash join at run time, after seeing how many rows arrived. Memory grant feedback corrects grants that were too small, causing spills, or too large, wasting memory, on later executions. Degree of parallelism feedback lowers the number of threads for queries that do not benefit from more. Cardinality estimation feedback adjusts modeling assumptions for queries whose estimates were repeatedly wrong. Approximate count distinct gives fast, slightly inexact counts for huge tables. Scalar function inlining turns many scalar functions into inline expressions.

Intelligent query processing features and where they help.
IQP featureProblem it addressesUse case at Harrow & Finch
Adaptive joinsWrong join type because of a bad estimateReport queries with varying date ranges
Memory grant feedbackSpills or wasted memory grantsSorts in the nightly report
Parameter sensitive plan optimizationOne cached plan for very different parameter valuesRedemptions by store, large and small
Degree of parallelism feedbackToo many threads for a query's benefitBusy reporting hours on the instance
Cardinality estimation feedbackRepeated misestimates from model assumptionsCorrelated filters on store and department
Approximate count distinctSlow exact distinct counts on huge tablesNumber of distinct members per month

The StockLine test copy came from SQL Server 2016 at compatibility level 130. Walt raises it to 160 on the test instance only after capturing a week of Query Store data at 130, so that if a query gets worse at the new level he can see its old plan and force it, which is the next lesson's subject. This is Microsoft's recommended upgrade path: keep the old compatibility level at first, collect a baseline, raise the level, and compare.

Hints, Query Store hints and plan forcing

Sometimes the right fix cannot be made in the code, because the query comes from a vendor's application. Query hints such as OPTION (RECOMPILE), MAXDOP or USE HINT change behavior for one statement but must be written into the query text. Query Store hints apply a hint to a query by its Query Store identifier, without touching the code, with sys.sp_query_store_set_hints. Plan forcing pins a query to a specific plan from Query Store with sp_query_store_force_plan. Both are powerful and both need tracking, because a forced plan or hint that made sense last year may be wrong after data grows. Walt keeps a list of every forced plan and hint with the reason and a review date.

Applying a Query Store hint to a vendor query and listing the hints in place.
-- Warehouse vendor query 4417 runs better without parallelism; apply a hint without changing its code
EXEC sys.sp_query_store_set_hints @query_id = 4417, @query_hints = N'OPTION (MAXDOP 1)';

-- Review every Query Store hint in the database
SELECT query_id, query_hint_text, last_query_hint_failure_reason_desc
FROM sys.query_store_query_hints;

Blocking, tempdb and other server settings

Not every slow query is the plan's fault. A query may be waiting for locks, as in lesson 8, or for tempdb, or for memory. On Managed Instance and SQL Server on VMs, server-level settings also matter: max degree of parallelism and cost threshold for parallelism control when and how widely queries go parallel, and on VMs, tempdb files on the local temporary disk and max server memory set below the VM's memory are the usual first steps. Database-scoped configurations, available on all three, set MAXDOP and several optimizer behaviors per database, which is how Azure SQL Database exposes most of these choices. DP-300 lists both kinds of setting.

Setting and checking database-scoped configurations.
ALTER DATABASE SCOPED CONFIGURATION SET MAXDOP = 4;
ALTER DATABASE SCOPED CONFIGURATION SET PARAMETER_SENSITIVE_PLAN_OPTIMIZATION = ON;
SELECT name, value FROM sys.database_scoped_configurations
WHERE name IN ('MAXDOP', 'PARAMETER_SENSITIVE_PLAN_OPTIMIZATION');

Worked problem

How much faster is the rewritten report, and was more compute needed?

The report query took 19 minutes on the test instance with 8 vCores. After the sargable rewrite it took 2 minutes 40 seconds. Before the rewrite, someone had proposed moving to 16 vCores, which would at best halve the time of a query limited by CPU, at roughly double the compute cost. Compare the speed-up from the rewrite with the best case from doubling vCores.

Show solution
  1. Convert to seconds: 19 minutes = 1,140 s; 2 minutes 40 seconds = 160 s.
  2. \[1{,}140 \div 160 \approx 7.1\]
  3. The rewrite made the query about 7 times faster, at no extra cost.
  4. Doubling vCores would at best make a CPU-bound query twice as fast: 1,140 ÷ 2 = 570 s, about 9.5 minutes, and this query was not even CPU-bound; it was hurt by a bad estimate, a poor join choice and a spill.
  5. Doubling compute would also double the compute part of the bill every month.

Answer: The rewrite gave a seven-fold speed-up, from 19 minutes to under 3, while doubling vCores would at best have reached about 9.5 minutes at twice the compute cost. Elena writes this on the whiteboard as the clinic's rule: read the plan before buying hardware.

Common mistakes

  • MistakeA slower query after migration means the new service tier is too small.

    InsteadRead the plan first; bad estimates, non-sargable predicates and spills are common causes.

  • MistakeEstimated plans show how many rows were actually processed.

    InsteadOnly actual plans or live statistics show actual row counts.

  • MistakeOPTION (RECOMPILE) is free.

    InsteadIt compiles on every execution, costing CPU; use it for rare or highly variable queries.

  • MistakeRaising the compatibility level never makes queries slower.

    InsteadSome queries regress; capture a Query Store baseline first so old plans can be forced if needed.

Summary and key terms

  • Execution plans show how a query runs; actual plans add real row counts and warnings such as spills.
  • Large gaps between estimated and actual rows reveal where the optimizer was misled.
  • Sargable predicates compare bare columns with values or ranges; functions on columns and implicit conversions prevent seeks.
  • Parameter sniffing can reuse a plan unsuited to other values; recompile, OPTIMIZE FOR, splitting or parameter sensitive plan optimization can help.
  • Intelligent query processing features, enabled through the compatibility level, fix many plan problems automatically.
  • Query Store hints and plan forcing change behavior without code changes but need a register and reviews.
Execution plan
The sequence of operators the engine uses to run a query.
Actual plan
An execution plan captured during execution, with real row counts and runtime warnings.
Sargable
Describes a predicate that can use an index seek and statistics, typically a bare column compared with a value.
Implicit conversion
An automatic data type conversion the engine adds when compared types differ.
Parameter sniffing
Compiling a plan for the parameter values of the first execution and reusing it.
Intelligent query processing
A family of engine features that adapt plans automatically, such as adaptive joins and memory grant feedback.
Query Store hint
A query hint applied to a query through Query Store without changing its text.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. In which direction is an execution plan read?

    Show answer

    From right to left, following the data toward the SELECT operator.

  2. Why is CONVERT(date, SaleTime) = @d a problem in a WHERE clause?

    Show answer

    The function on the column prevents an index seek and good use of statistics.

  3. What does memory grant feedback correct?

    Show answer

    Grants that were too small, causing spills, or too large, wasting memory, on later executions.

  4. How can a DBA change a vendor query's behavior without editing its code?

    Show answer

    With a Query Store hint or by forcing a plan.

  5. Why keep the old compatibility level for a week after migration?

    Show answer

    To capture a baseline in Query Store so regressions after raising it can be found and old plans forced.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz