Day 22: Query execution plans
On this page
By the end of this lesson you can
- Display estimated and actual execution plans in SSMS and read them in the right order.
- Recognise the common operators — scans, seeks, lookups, the three join types, sorts and aggregates — and say what each implies.
- Use
SET STATISTICS IO, TIME ONand explain logical reads, physical reads, CPU time and elapsed time. - Compare estimated with actual row counts and explain how statistics produce estimates.
- Inspect a statistics histogram with
DBCC SHOW_STATISTICS. - Record a reproducible performance baseline to compare against after tuning.
A. Lesson overview
| Week | 4 — Performance and expert topics |
| Level | Advanced |
| Estimated time | 3 h essential (including about 10 minutes of data generation) + 30 min optional stretch |
| Prerequisites | Days 1–21; comfortable reading joins and aggregates |
| Syllabus topic | Query Execution Plans — estimated vs actual plans, operators, statistics, logical reads |
Activity breakdown
| Activity | Time |
|---|---|
| Generate the performance dataset (script 05) | 10–15 min |
| Part 1 — How SQL Server runs a query | 15 min |
| Part 2 — Getting and reading a plan | 20 min |
| Part 3 — The operators you will meet most | 20 min |
Part 4 — Measuring: STATISTICS IO and STATISTICS TIME |
15 min |
| Part 5 — Statistics and estimates | 15 min |
| Worked examples | 30 min |
| Guided lab (record a baseline) | 25 min |
| Independent practice | 15 min |
| Knowledge check and wrap-up | 5 min |
| Stretch challenge (optional) | 30 min |
Business scenario. The retailer’s history has grown to a million orders. Some reports and screens have become slow. Before anyone adds indexes or rewrites queries, the team needs to see what SQL Server is doing and measure it — guessing is how performance work goes wrong.
About numbers in Week 4. Row counts and query results in these lessons are exact: the data is deterministic and the values were computed independently from the generation formulas. Timings, logical reads and some plan choices are not: they depend on your hardware, memory, number of CPUs and SQL Server’s decisions. Wherever a number depends on your machine, the lesson asks you to measure and record it rather than quoting one.
B. Theory explained clearly
Part 1 — How SQL Server runs a query
- Parse — check the syntax.
- Bind — resolve names (tables, columns), check permissions and types.
- Optimise — the query optimiser considers many ways to get the answer (which index, which join order, which join algorithm) and estimates the cost of each, mainly from statistics about the data. It picks a plan that looks cheap — not necessarily the best possible, but good enough, found quickly.
- Cache — the plan is stored in the plan cache and reused for later executions of the same query text.
- Execute — the plan’s operators read and process the rows.
The execution plan is the optimiser’s chosen recipe. Reading it tells you what SQL Server actually does with your query.
Part 2 — Getting and reading a plan
| In SSMS | What you get |
|---|---|
| Display Estimated Execution Plan (Ctrl + L) | The plan the optimiser would use, without running the query. Estimates only |
| Include Actual Execution Plan (Ctrl + M), then run | The plan used, plus actual row counts, executions, warnings and runtime information |
| Include Live Query Statistics | Watch rows flow through the plan while a long query runs |
Reading order. Data flows from right to left; the leftmost operator (SELECT) returns results to you. Start at the top right, follow the arrows left. Arrow thickness shows the number of rows.
Properties. Hover over an operator for a tooltip, or select it and press F4 for the full Properties window. The most useful properties:
| Property | Why it matters |
|---|---|
| Actual Number of Rows (for All Executions) vs Estimated Number of Rows | Large differences mean the optimiser was working from wrong assumptions |
| Number of Executions | An operator inside a nested loop may run thousands of times |
| Estimated Operator Cost (%) | The optimiser’s estimate of relative cost — a guide, not a measurement |
| Seek Predicates / Predicate | A seek predicate uses the index to go straight to rows; a predicate filters rows after reading them |
| Warnings (yellow triangle) | Implicit conversions, spills to tempdb, missing statistics, excessive memory grants |
A green Missing Index hint may appear above the plan. It is a suggestion from the optimiser, for this one query; Day 23 explains why to treat it with care.
Part 3 — The operators you will meet most
| Operator | What it does | Usually means |
|---|---|---|
| Clustered Index Scan / Table Scan | Reads every row of the table (clustered index or heap) | No useful index for the filter — or most rows are needed anyway |
| Clustered Index Seek | Navigates the B-tree straight to matching rows | Filter on the clustered key |
| Index Seek (nonclustered) | Navigates a nonclustered index | Good — if the index has all the columns needed |
| Key Lookup | For each row from a nonclustered index, fetches the remaining columns from the clustered index | The index doesn’t cover the query; expensive for many rows |
| Nested Loops | For each row from the top input, looks up matching rows in the bottom input | Efficient when the top input is small and the bottom is seekable |
| Hash Match (join or aggregate) | Builds a hash table from one input, probes with the other | Large, unsorted inputs; needs memory |
| Merge Join | Walks two inputs sorted on the join key | Both inputs already sorted (e.g. by index order) |
| Sort | Orders rows | ORDER BY, merge joins, some aggregates; needs memory, may spill |
| Stream Aggregate | Aggregates rows that arrive in group order | Grouping on an index order, or a scalar aggregate (COUNT(*)) |
| Compute Scalar | Calculates expressions | Usually cheap |
| Parallelism (Gather/Repartition Streams) | Splits work across several CPU threads and combines it | The query was costly enough for a parallel plan |
Part 4 — Measuring: STATISTICS IO and STATISTICS TIME
SET STATISTICS IO, TIME ON;
-- run the query, then look at the Messages tab
SET STATISTICS IO, TIME OFF;
Messages look like this (numbers vary):
Table 'SalesOrder'. Scan count 1, logical reads 6123, physical reads 0, read-ahead reads 0, …
SQL Server Execution Times:
CPU time = 94 ms, elapsed time = 98 ms.
| Measure | Meaning |
|---|---|
| Logical reads | 8 KB pages read from memory (the buffer pool). The most stable measure for comparing two versions of a query: it doesn’t depend on what else the machine is doing |
| Physical reads / read-ahead reads | Pages that first had to be read from disk. High on the first run (“cold cache”), usually 0 afterwards |
| Scan count | How many times the table/index was accessed — not necessarily a full scan |
| CPU time | Processor time used, across all threads — can exceed elapsed time in a parallel plan |
| Elapsed time | Wall-clock time, including waiting. What users feel, but noisy |
Rule for this week: run each query twice and record the second run (warm cache), focusing on logical reads.
Part 5 — Statistics and estimates
The optimiser estimates how many rows each step will produce using statistics objects. Each statistics object describes one or more columns:
- a histogram of up to 200 steps for the first column — for each step, the highest value (
RANGE_HI_KEY), the number of rows equal to it (EQ_ROWS), and the rows and distinct values between steps; - density — 1 ÷ number of distinct values, used when the value isn’t known at compile time.
SQL Server creates statistics automatically for every index and for columns you filter on (AUTO_CREATE_STATISTICS, on by default), and updates them after enough changes (AUTO_UPDATE_STATISTICS). The script ran UPDATE STATISTICS … WITH FULLSCAN so everyone starts with accurate statistics.
When estimates are badly wrong — because statistics are out of date, the value is unknown (a local variable), or the data is skewed — the optimiser can choose a poor plan: a nested loop that runs a million times, a memory grant that’s too small and spills to tempdb. Comparing estimated and actual rows is the first diagnostic step for a slow query.
Query Store (enabled by default for new databases on SQL Server 2022 and later) records plans and runtime statistics over time; the stretch uses it.
C. Dataset and starting state
| Dataset | The Perf schema — large deterministic dataset |
| Database | RetailLab |
| Created by | scripts/setup/05_generate_performance_data.sql |
| Read from | Perf.Customer, Perf.SalesOrder, Perf.SalesOrderLine, Perf.Product |
| Changed | Lab.PerfBaseline (your measurements); statistics object st_PerfSalesOrder_CustomerID |
| Permissions | CREATE TABLE/CREATE SCHEMA (script 05), SHOWPLAN to view plans (owner has it), CREATE STATISTICS (ALTER on the table) |
| Disk space | A few hundred MB for data and log |
Generate the data. Open scripts/setup/05_generate_performance_data.sql (download it — it is also in the setup zip) and run it (F5). It is destructive for the Perf schema only: it drops and recreates every Perf table. It prints a line per 200,000 orders and finishes with a validation table.
| Table | Rows | Key facts |
|---|---|---|
Perf.Product |
30 | Copy of Inventory.Product (products 1–29 are ordered) |
Perf.Customer |
100,000 | CustomerID, CustomerName, Email, LoyaltyCardNo (digits stored as varchar), Region (5 values), Segment, DateJoined |
Perf.SalesOrder |
1,000,000 | 2022-01-01 to 2025-12-31; CustomerID, OrderDate, SalesChannel, OrderStatus (Delivered / Pending / Cancelled), ShippingCost, Notes |
Perf.SalesOrderLine |
3,000,000 | 1–5 lines per order; OrderLineID has deliberate gaps |
Customer 1 has 50,000 orders; every other customer has 9 or 10. Only the clustered primary keys exist — no other indexes.
Validation. All 17 rows in the script’s final result should show PASS. To re-check later:
SELECT (SELECT COUNT(*) FROM Perf.Customer) AS Customers, -- 100000
(SELECT COUNT(*) FROM Perf.SalesOrder) AS Orders, -- 1000000
(SELECT COUNT(*) FROM Perf.SalesOrderLine) AS OrderLines, -- 3000000
(SELECT COUNT(*) FROM sys.indexes
WHERE object_id IN (OBJECT_ID('Perf.SalesOrder'), OBJECT_ID('Perf.SalesOrderLine'), OBJECT_ID('Perf.Customer'))
AND index_id > 1) AS NonclusteredIndexes; -- 0
Baseline table. You will record measurements today and compare them on Days 23 and 24:
CREATE TABLE Lab.PerfBaseline
(
RunID int IDENTITY(1,1) NOT NULL PRIMARY KEY,
QueryName varchar(40) NOT NULL,
LessonDay tinyint NOT NULL,
LogicalReads bigint NULL, -- total across tables, from STATISTICS IO
CpuMs int NULL,
ElapsedMs int NULL,
PlanShape nvarchar(200) NULL, -- e.g. 'Clustered Index Scan + Stream Aggregate'
RecordedAt datetime2(0) NOT NULL DEFAULT (SYSDATETIME())
);
(If it already exists from a previous attempt, keep it.)
D. Worked examples
Turn on Include Actual Execution Plan (Ctrl + M) for all of today’s examples.
Worked example 1 — A seek on the clustered key
SET STATISTICS IO ON;
SELECT OrderID, CustomerID, OrderDate, OrderStatus
FROM Perf.SalesOrder
WHERE OrderID = 123456;
SET STATISTICS IO OFF;
Expected result: one row: 123456, customer 57842, 2024-02-26, Delivered. Plan: a single Clustered Index Seek with a Seek Predicate on OrderID. Logical reads: a handful (one per level of the B-tree — typically 3 for a table this size). This is the best case: SQL Server walks from the root of the index to one leaf page.
Worked example 2 — The same table, filtered on a column with no index
SET STATISTICS IO ON;
SELECT OrderID, OrderDate, OrderStatus
FROM Perf.SalesOrder
WHERE CustomerID = 2
ORDER BY OrderID;
SET STATISTICS IO OFF;
Expected result: 10 rows — order IDs 99999, 199998, 299997, … 999990. Plan: Clustered Index Scan with a Predicate (not a seek predicate) on CustomerID, probably with a green Missing Index suggestion; possibly parallel. Logical reads: thousands — every page of the table — to return 10 rows. Note the number; it is today’s most important baseline.
Worked example 3 — Joins and aggregation
SET STATISTICS IO, TIME ON;
SELECT c.Region,
COUNT(DISTINCT o.OrderID) AS Orders,
CAST(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS decimal(14,2)) AS NetSales
FROM Perf.SalesOrder AS o
JOIN Perf.Customer AS c ON c.CustomerID = o.CustomerID
JOIN Perf.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
AND o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01'
GROUP BY c.Region
ORDER BY NetSales DESC, c.Region;
SET STATISTICS IO, TIME OFF;
Expected result:
| Region | Orders | NetSales |
|---|---|---|
| South | 56950 | 50429104.31 |
| West | 46969 | 47866217.66 |
| Central | 46967 | 47848585.23 |
| North | 46971 | 47845785.97 |
| East | 46972 | 47825162.66 |
South is larger because customer 1 is in the South region. Plan: typically Hash Match joins and aggregates over scans of all three tables, often with Parallelism operators — large, unsorted inputs are exactly what hash joins are for. The STATISTICS IO output lists each table with its own logical reads (and possibly Worktable/Workfile lines for hash operations). CPU time may be greater than elapsed time if the plan is parallel.
Worked example 4 — Estimates come from statistics
CREATE STATISTICS st_PerfSalesOrder_CustomerID ON Perf.SalesOrder (CustomerID) WITH FULLSCAN;
DBCC SHOW_STATISTICS ('Perf.SalesOrder', 'st_PerfSalesOrder_CustomerID') WITH STAT_HEADER;
DBCC SHOW_STATISTICS ('Perf.SalesOrder', 'st_PerfSalesOrder_CustomerID') WITH DENSITY_VECTOR;
DBCC SHOW_STATISTICS ('Perf.SalesOrder', 'st_PerfSalesOrder_CustomerID') WITH HISTOGRAM;
Expected result: header: Rows 1000000, Rows Sampled 1000000; density vector: All density 1E-05 (1 ÷ 100,000 distinct customers); histogram: up to 200 steps, the first with RANGE_HI_KEY 1 and EQ_ROWS 50000 — the statistics know customer 1 is special. Now compare estimates:
SELECT COUNT(*) FROM Perf.SalesOrder WHERE CustomerID = 1; -- estimate 50000, actual 50000
SELECT COUNT(*) FROM Perf.SalesOrder WHERE CustomerID = 2; -- estimate small (around 10), actual 10
In the actual plan, select the scan operator and compare Estimated Number of Rows with Actual Number of Rows. With a literal value the optimiser looks it up in the histogram. (If AUTO_CREATE_STATISTICS already made a _WA_Sys_… statistic on CustomerID during worked example 2, both exist; that’s fine.)
E. Guided practical lab — Record a performance baseline
You’ll measure five queries that Days 23 and 24 will improve. For each: run once to warm the cache, run again, then record the second run’s numbers.
Step 1 — Prepare
- Do: Turn on plans and measurements.
- Run:
USE RetailLab; GO SET STATISTICS IO, TIME ON; - Observe: Nothing yet; the next queries will print to Messages.
- Why it matters: Measurements are only comparable if collected the same way each time.
- Confirm: Ctrl + M is on (the toolbar button is highlighted).
Step 2 — Run the five baseline queries
- Do: Run each query twice; on the second run, note total logical reads (add up the tables), CPU and elapsed time, and the main operators.
- Run:
-- Q1: one customer's orders SELECT OrderID, OrderDate, OrderStatus FROM Perf.SalesOrder WHERE CustomerID = 12345 ORDER BY OrderID; -- Q2: one month of orders SELECT COUNT(*) AS Orders, SUM(ShippingCost) AS Shipping FROM Perf.SalesOrder WHERE OrderDate >= '2025-12-01' AND OrderDate < '2026-01-01'; -- Q3: find a customer by email SELECT CustomerID, CustomerName, Region FROM Perf.Customer WHERE Email = 'customer12345@example.com'; -- Q4: the pending-orders screen SELECT OrderID, CustomerID, OrderDate FROM Perf.SalesOrder WHERE OrderStatus = 'Pending' ORDER BY OrderDate, OrderID; -- Q5: top five products by 2025 net sales SELECT TOP (5) p.ProductCode, CAST(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS decimal(14,2)) AS NetSales FROM Perf.SalesOrder AS o JOIN Perf.SalesOrderLine AS l ON l.OrderID = o.OrderID JOIN Perf.Product AS p ON p.ProductID = l.ProductID WHERE o.OrderStatus <> 'Cancelled' AND o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01' GROUP BY p.ProductCode ORDER BY NetSales DESC, p.ProductCode; - Observe: Results — Q1: 10 rows; Q2:
21216,71716.28; Q3: customer12345, Customer 12345, North; Q4: 11402 rows; Q5: NAV-004 22195143.60, TNT-002 22192770.40, SLP-002 19010719.00, NAV-002 19007579.50, TNT-003 15826763.70. Plans: Q1–Q4 scan the whole table (clustered index scan); Q5 scans orders and lines with hash joins. - Why it matters: Q1–Q4 return tiny fractions of their tables but read all of them. That gap is the opportunity for Day 23.
- Confirm: You have five sets of numbers.
Step 3 — Save the numbers
- Do: Insert your measurements (replace the zeros with your values).
- Run:
INSERT INTO Lab.PerfBaseline (QueryName, LessonDay, LogicalReads, CpuMs, ElapsedMs, PlanShape) VALUES ('Q1 customer orders', 22, 0, 0, 0, N'Clustered Index Scan'), ('Q2 month of orders', 22, 0, 0, 0, N'Clustered Index Scan'), ('Q3 customer by email',22, 0, 0, 0, N'Clustered Index Scan'), ('Q4 pending orders', 22, 0, 0, 0, N'Clustered Index Scan + Sort'), ('Q5 top products 2025',22, 0, 0, 0, N'Scans + Hash Match joins'); SELECT * FROM Lab.PerfBaseline WHERE LessonDay = 22 ORDER BY RunID; - Observe: 5 rows with your own numbers.
- Why it matters: Without a recorded “before”, you can’t prove an improvement “after”.
- Confirm:
PlanShapedescribes what your plans showed — edit the text if your plan differs.
Step 4 — Estimated vs actual on Q4
- Do: In Q4’s actual plan, open the properties (F4) of the scan.
- Run: (no new code)
- Observe: Estimated Number of Rows close to 11402 — SQL Server auto-created statistics on
OrderStatusthe first time you filtered on it; Number of Rows Read 1000000; Actual Number of Rows 11402. - Why it matters: “Rows read” vs “rows returned” is a direct measure of wasted work: a million rows read for eleven thousand returned.
- Confirm: You can find all three numbers.
Step 5 — Switch off
- Do:
SET STATISTICS IO, TIME OFF; - Run: as shown.
- Observe: Messages no longer show reads and times.
- Why it matters: Leave sessions as you found them.
- Confirm: Run Q3 again; Messages shows only (1 row affected).
F. Independent practice
Task 1 — Order without a sort
Compare the plans for the five newest orders sorted two ways: ORDER BY OrderID DESC and ORDER BY OrderDate DESC, OrderID DESC (both TOP (5), columns OrderID, CustomerID, OrderDate). Which needs a sort and why?
Success criteria: By date: 999245 (customer 288), 997784 (30513), 996323 (60738), 994862 (90963), 993401 (21189), all 2025-12-31. By ID: 1000000 down to 999996. The OrderID version reads the clustered index backwards with no Sort and very few reads; the OrderDate version scans everything and uses a Top N Sort.
Task 2 — A local variable changes the estimate
Run SELECT COUNT(*) FROM Perf.SalesOrder WHERE CustomerID = @c; with DECLARE @c int = 1;. Compare the estimated rows with the literal version from worked example 4. Then add OPTION (RECOMPILE) and compare again.
Success criteria: With the variable: estimate 10 (1,000,000 × density 0.00001), actual 50000. With OPTION (RECOMPILE): estimate 50000. Explain in one sentence.
Task 3 — Read a join plan
Run: customer 12345’s orders with customer name — Perf.Customer joined to Perf.SalesOrder on CustomerID, filtered on c.CustomerID = 12345. Name the join operator, which table is accessed by seek and which by scan, and how many times each operator executed.
Success criteria: 10 rows. Typically: a Clustered Index Seek on Perf.Customer (1 row) and a Clustered Index Scan of Perf.SalesOrder (no index on CustomerID), joined by Nested Loops; each operator has Number of Executions 1 (or one per thread in a parallel plan). The filter c.CustomerID = 12345 was applied to o.CustomerID as well — check the scan’s predicate.
Task 4 — Parallel or not (challenging)
Run Q5 with OPTION (MAXDOP 1) and without. Record CPU and elapsed time for both. Explain the difference.
Success criteria: Same five rows. Typically the default plan shows Parallelism operators, with CPU time higher than elapsed time; MAXDOP 1 has no Parallelism operators and CPU ≈ elapsed. Your numbers are your own — the pattern is what matters.
Stretch challenge — Query Store
Check that Query Store is on, then find the query with the most total logical reads recorded today, with its execution count and average duration.
Success criteria: actual_state_desc is READ_WRITE; the top query by logical reads is one of today’s large scans or joins (most likely Q5 or worked example 3).
G. Solutions and explanations
Show solution 1
SELECT TOP (5) OrderID, CustomerID, OrderDate FROM Perf.SalesOrder ORDER BY OrderID DESC;
SELECT TOP (5) OrderID, CustomerID, OrderDate FROM Perf.SalesOrder ORDER BY OrderDate DESC, OrderID DESC;
Reasoning. The clustered index is stored in OrderID order, so the newest five IDs are the last five rows: a backward ordered scan that stops after five rows. Nothing is stored in OrderDate order, so SQL Server must read all rows and keep the top five in a Top N Sort. An index on OrderDate (Day 23) would remove the sort.
Show solution 2
DECLARE @c int = 1;
SELECT COUNT(*) FROM Perf.SalesOrder WHERE CustomerID = @c;
SELECT COUNT(*) FROM Perf.SalesOrder WHERE CustomerID = @c OPTION (RECOMPILE);
Reasoning. When the batch is compiled, a local variable’s value isn’t known, so the optimiser uses the average from the density vector: 1,000,000 × 0.00001 = 10 rows. OPTION (RECOMPILE) compiles the statement when it runs, with the variable’s current value, so the histogram is used (50,000). A 5,000-fold misestimate is harmless for COUNT(*) but can produce a terrible join or memory grant in a bigger query. Day 24 builds on this with parameters and parameter sniffing.
Show solution 3
SELECT c.CustomerName, o.OrderID, o.OrderDate
FROM Perf.Customer AS c
JOIN Perf.SalesOrder AS o ON o.CustomerID = c.CustomerID
WHERE c.CustomerID = 12345
ORDER BY o.OrderID;
Reasoning. The optimiser infers o.CustomerID = 12345 from the join and the filter (transitive predicate), so it can filter orders during the scan. One customer row joined to ten order rows makes nested loops a natural choice. The plan still scans a million orders, because there is no index on Perf.SalesOrder.CustomerID — Day 23’s first fix.
Show solution 4
SET STATISTICS TIME ON;
-- Q5 as in the lab (default)
-- Q5 again, adding OPTION (MAXDOP 1) after the ORDER BY
SET STATISTICS TIME OFF;
Reasoning. In a parallel plan several threads work at once: total CPU across threads can exceed the wall-clock time. With MAXDOP 1 one thread does all the work, so elapsed time usually rises and CPU ≈ elapsed. Parallelism trades extra CPU (and coordination) for speed. Server-level settings max degree of parallelism and cost threshold for parallelism govern it; don’t change them on a shared server without good reason.
Show the stretch solution
SELECT actual_state_desc, desired_state_desc, query_capture_mode_desc
FROM sys.database_query_store_options;
SELECT TOP (5)
q.query_id,
LEFT(qt.query_sql_text, 80) AS QueryStart,
SUM(rs.count_executions) AS Executions,
SUM(rs.avg_logical_io_reads * rs.count_executions) AS TotalLogicalReads,
CAST(AVG(rs.avg_duration) / 1000.0 AS decimal(12,1)) AS AvgDurationMs
FROM sys.query_store_query AS q
JOIN sys.query_store_query_text AS qt ON qt.query_text_id = q.query_text_id
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
GROUP BY q.query_id, LEFT(qt.query_sql_text, 80)
ORDER BY TotalLogicalReads DESC;
Reasoning. Query Store keeps history across restarts, unlike the plan cache. avg_duration is in microseconds. Runtime statistics are flushed periodically, so a query you ran seconds ago may take a minute or so to appear. In SSMS, Database → Query Store → Top Resource Consuming Queries shows the same information graphically. If actual_state_desc is OFF, enable it with ALTER DATABASE RetailLab SET QUERY_STORE = ON;.
H. Knowledge check
-
Understanding. In which direction does data flow in a graphical plan, and where do you start reading?
-
Query interpretation. A plan shows an Index Seek feeding a Key Lookup through Nested Loops. The Key Lookup’s Number of Executions is 40,000. What does that mean, and why is it a concern?
-
Understanding. Why are logical reads a better comparison measure than elapsed time when tuning a query?
-
Debugging. A query’s plan shows Estimated Number of Rows 1 and Actual Number of Rows 250,000 for a scan feeding a nested loop. Give two possible causes.
-
Understanding. What is the difference between a Seek Predicate and a Predicate on a scan?
Show the answers
-
Right to left (and top to bottom for each branch). Start at the top-right operator and follow the arrows to the
SELECTon the left. -
For each of 40,000 rows from the nonclustered index, SQL Server looked up the remaining columns in the clustered index — 40,000 separate B-tree navigations. It’s a sign the index doesn’t cover the query; adding included columns or a different index may remove the lookups.
-
Logical reads count the work done (pages touched) and don’t depend on other activity, caching or hardware; elapsed time varies from run to run.
-
Out-of-date or missing statistics; a local variable or unknown value at compile time; skewed data where the value is unusual; complex expressions or functions in the predicate that the optimiser can’t estimate; a table variable or multi-statement function with a fixed guess.
-
A seek predicate is used to navigate the index directly to matching rows. A predicate is evaluated against every row read — the rows are read and then filtered.
I. Troubleshooting and cleanup
| Error or symptom | Cause | Fix |
|---|---|---|
| Script 05: ‘GENERATE_SERIES’ is not a recognized built-in function name. | Compatibility level below 160 | ALTER DATABASE RetailLab SET COMPATIBILITY_LEVEL = 170; |
| Script 05: The transaction log for database ‘RetailLab’ is full… | Log can’t grow (disk full or size limit) | Free disk space; check ALTER DATABASE … MODIFY FILE limits; re-run |
Script 05 shows a FAIL row |
Interrupted or altered run | Re-run the whole script |
| No plan tab after running | Actual plan not switched on | Ctrl + M, then run again |
| Plan differs from the lesson (e.g. parallel vs serial) | Different hardware, CPU count, memory | Expected; compare logical reads and the operators involved |
| SHOWPLAN permission denied… | Missing permission | GRANT SHOWPLAN TO <user>; (the owner already has it) |
| First run much slower than second | Cold cache — physical reads | Compare warm runs |
Cleanup. Keep the Perf tables, the statistics object and Lab.PerfBaseline: Days 23–25 use them. To free space at the end of Week 4, see Day 25’s cleanup.
J. Completion checklist, recap and references
Completion checklist
- Script 05 finished with 17
PASSrows. - You saw a seek (worked example 1) and a scan returning 10 rows from a million (worked example 2).
- The region totals matched; you found the hash joins in the plan.
- You read the histogram’s first step:
RANGE_HI_KEY1,EQ_ROWS50000. -
Lab.PerfBaselineholds your five measurements.
Recap
- The optimiser picks a plan using cost estimates based on statistics; read plans right to left and check operator properties.
- Scans read everything; seeks navigate; key lookups add a seek per row; nested loops, hash and merge joins suit different input sizes and orders.
- Compare logical reads first; record warm-cache measurements.
- Large gaps between estimated and actual rows point to statistics or unknown values.
- Record a baseline before changing anything.
Next: Day 23 — Indexing Strategies: clustered and nonclustered indexes, covering and included columns, filtered and columnstore indexes, and maintenance — applied to today’s five queries.