Residual Predicates

Overview

An index seek is not always good news. It is a range. An index on CustomerID seeking for customer 42 and then testing Status = 'Open' reads every order customer 42 ever placed to hand back the three that are open. The plan still says Index Seek, and nothing warns about it.

The test the seek could not use is a residual predicate: a column the index carries, but not in a place the seek can use. The waste shows in two numbers on the operator, the rows it read (EstimatedRowsRead) beside the rows it kept (EstimateRows), and the reason is in its predicate.

This report reads the busiest plans Query Store holds and, for every seek and scan that tested rows after reading them, says:

  • which index was read, and which columns the seek used,
  • which column was tested only after reading,
  • how many rows were read for every row kept,
  • what it cost to read the ones thrown away,
  • what would fix it: a longer key, a second index, or a rewritten predicate.

Plan Warnings lists the warnings a plan raises, and a residual predicate raises none. Key Lookups names the columns a lookup went back for, a different operator with a different fix: an included column cannot be sought on.

The Residual Predicates report: the chart above the grid
The whole page on a database with a month of Query Store history.

Why key order matters

A seek can only use an index key from the front. An index on (CustomerID, OrderDate, Status) can seek on CustomerID, or on CustomerID and OrderDate, but not on Status alone, and a seek stops at the first key compared with a range. So the same column can be tested after reading for four different reasons, each with a different fix:

Where the column is Fix
After a key the predicate does not mention A second index with the keys in the order the predicate uses them.
After a key sought with a range A second index with the equality column in front of the range.
In the INCLUDE list, on an index whose seek used every key for equality Move it to the end of the key, in place. Every other seek on the index keeps working.
Inside a function or a conversion No key order helps. Rewrite the predicate.

The page extends a key in place only when that is safe for everything else that uses the index: a plain nonclustered index, not unique, not behind a constraint, whose seek used every key for equality. A longer unique key would start accepting duplicates, and a clustered key is carried in every other index, so those always get a second index instead.


Where to find it

In the tree, under a database, Real Time → Query Store → Residual Predicates.

The report needs SQL Server 2016 or newer. EstimatedRowsRead is written into plans from SQL Server 2016 SP1, so on SQL Server 2016 RTM seeks are counted as unmeasured.


Requirements

Requirement Why
SQL Server 2016 SP1 or newer Query Store arrived in SQL Server 2016, and the rows read attribute in 2016 SP1.
Query Store on for the database The plans and their runtime statistics live in Query Store.
VIEW DATABASE STATE To read the Query Store catalog views and sys.dm_db_partition_stats.
VIEW SERVER STATE (optional) For the update counts quoted in the verdict. Without it the page still loads.

The toolbar

Control What it does
Logical reads / CPU / Duration / Executions What “busiest” means, and the currency every cost on the page is in. Logical reads is the default, because a row read and thrown away is a read.
1 h / 6 h / 24 h / 7 d The window of Query Store history. It ends at the last completed interval.
25 / 50 / 100 / 200 plans How many of the busiest plans are read.
Turn Query Store on Only when Query Store is off and a setting would fix it.
Refresh Re-reads Query Store.

The choices are remembered for the next visit.


Reading the chart

The Residual Predicates chart
The chart on its own, from the same capture.

At the top, a verdict names the index read most wastefully, how many rows it read for each row kept, why the seek could not skip them, and what the fix is. When nothing needs a key change it names a predicate to rewrite, or says the busiest plans read little they throw away.

Four tiles follow:

Tile What it is
Spent on rows thrown away What reading the discarded rows cost, and its share of the window.
Rows read, then discarded Estimated rows thrown away, out of the rows read.
Keys to change Indexes that need a longer key or a second index. Click to show only those.
Predicates to rewrite Tests hidden inside a function or a data type conversion. Click to filter.

The key reach diagram

Each row of the chart is one index drawn as its definition:

  • The key columns in key order, a divider, then the included columns. On a clustered index or a heap, the tested columns are shown with a “rest of the row” box.
  • A bar under the keys shows how far the seek reached. It always starts at the first key and stops at the first key the seek did not use.
  • A dotted line runs from where the seek stopped to the furthest column tested after reading: the rows read and thrown away.
  • An arc runs from each tested column to the place in the key where it would let the seek skip those rows, with a tick at that place.
  • On the right, the rows read and kept per run, and a bar for the cost, on one scale for every row.

The boxes differ by shape, not color:

Box Meaning
Filled Sought for equality.
Lighter, with a chevron Sought as a range. The seek goes no further.
Dashed outline with a filter mark Tested after reading: solid mark for equality, hollow for a range.
Hatched, dashed outline Tested inside a function or a conversion. It never gets an arc.
Plain outline Nothing happened to this column.

Color grades the row: orange is a key to change, red is urgent (over 5% of the window), blue is a predicate to rewrite, green is already keyed, and gray costs too little to matter. Rows whose filter keeps most of what it reads are left off the chart. Up to twelve rows are drawn; the rest are in the grid. Hover over a row for its columns, the proposed change and the reason.


Reading the grid

The Residual Predicates grid
The first rows of the grid, from the same capture.

One row per index read one way: the same columns sought and the same columns tested. Two plans reading an index the same way are one row, because one change serves both.

Column What it is
# Rank by what reading the discarded rows cost.
Index The index read, or (heap).
Table The table.
Finding Add a column to the key, needs an index keyed a certain way, a function or data type mismatch hides the column, keyed since the plans compiled, the filter keeps most of what it reads, or rows thrown away that cost little.
Proposed key The key a seek could use.
Notes The reason, what cannot be fixed by a key, wide keys, filters, and when the plans last ran.
Read per kept Rows read for every row kept. Under 4 is a filter doing its job.
Logical reads (or the chosen measure) The cost of reading the rows thrown away: the plan’s cost, times the operator’s share of the plan, times the share of its rows discarded.
Share That cost as a share of everything every plan in the window spent.
Rows read / Rows kept Estimated over the window.
Queries How many distinct queries read the index this way.
Sought on The columns the seek used, with (range) on a range, or “nothing, a scan”.
Tested after reading The residual columns, with (range), (in a function) or (converted).

The Notes column takes the width the others leave, so it grows with the window. The last columns (Rows read, Rows kept, Queries, Sought on and Tested after reading) sit past the edge of a 1280 pixel screen; scroll right for them, or hover over a row, whose tooltip lists every column in full.

Double-click a row for an explanation. Right-click for:

Action What it does
Explain this index The columns, the proposed key, the rows and the reason, in words.
Show the busiest statement that reads it this way The statement in the query window.
Copy the key extension script / Copy the new index script The statement, copied to the clipboard. Nothing is run.
Copy the rewrite advice For a predicate no index can help: how to rewrite it.
Copy the recompile statement For an index already keyed correctly: sp_recompile, commented out.
Go to Key Lookups The companion report.
Copy the query behind this report The batches the page runs, ready for SSMS.

About the script

  • A key extension rebuilds the index with DROP_EXISTING = ON and the new columns on the end of the key. A column moving out of the INCLUDE list is listed once, in the key.
  • A second index is keyed equality columns first and at most one range column last. Beside a nonclustered index it includes that index’s other columns, so the plans reading the old index could read the new one. Nothing is dropped.
  • A filter is copied, with SET QUOTED_IDENTIFIER ON first. Data compression is written out.
  • ONLINE = ON is written only on Enterprise and Developer Edition.
  • A proposed key declared wider than 1,700 bytes carries a warning.

Where the data comes from

Source What it gives
sys.query_store_runtime_stats and ..._interval Executions and totals per plan in the window.
sys.query_store_plan The plan document, query_plan, for the busiest plans only.
sys.query_store_query, ..._query_text The statement and its object.
sys.indexes, sys.index_columns, sys.columns Each index’s keys in order, included columns, and column types.
sys.dm_db_partition_stats, sys.partitions Rows, size and compression.
sys.dm_db_index_usage_stats Updates since SQL Server last started.

How the plans are read

The documents are returned as text and read by Database Health Monitor, not with XQuery on the server, because a plan nested deeper than 128 levels cannot be shredded with the XML methods. For every seek and scan the reader takes:

  • the seek columns from the seek keys, where a range names its column twice (start and end) and counts once, and where the values sought, which can be another table’s columns in a join, are ignored,
  • the residual columns from the operator’s predicate, marked as a function when a function, arithmetic or conversion sits between the column and the comparison, and as a data type mismatch when that conversion is implicit,
  • EstimatedRowsRead and EstimateRows, over every run of the operator.

Lookups are left to Key Lookups. Columnstore scans are skipped, because a predicate pushed into a columnstore scan is how that storage is meant to be read. A scan without EstimatedRowsRead is taken as reading the whole table; a seek without it is counted as unmeasured.

What the numbers are

  • Totals count regular executions only, weighted by executions. Aborted and failed executions are counted in the footer.
  • The window ends at the last completed Query Store interval.
  • Rows read and kept are the optimizer’s estimates from the plan Query Store captured first.
  • Predicates on temporary tables, system tables and tables in other databases are counted in the footer and left out of the grid.

Messages you may see

None of the busiest plans tests rows after reading them. The healthy case. Read more plans or rank by executions to look further down.

Every index read this way keeps most of what it reads. Seeks with residual predicates exist, but none reads four rows or more for each row it keeps.

N seeks with a residual predicate did not record EstimatedRowsRead. Plans compiled before SQL Server 2016 SP1 leave it out, and those seeks cannot be measured.

Keyed since these plans compiled. The index already has the tested columns where a seek can use them. The plans were compiled before it changed and will seek properly on their next recompile.


Report Why you would go there
Key Lookups Seeks that go back to the table for more columns.
Missing The indexes the optimizer asked for while planning.
Page Reads by Query The queries doing the reading, whatever the operator.
Plan Warnings Implicit conversions the plans warn about.
Statistics When the row estimates themselves look wrong.

Frequently asked questions

The plan says Index Seek. Why is it on this page? A seek reads a range. If the range holds a thousand rows and the predicate keeps ten, the seek read nine hundred and ninety rows for nothing, and only the rows read and rows kept numbers show it.

Why a second index rather than changing the one that is there? Changing the key order of an index changes it for every other query that uses it. The page only extends a key in place when every existing seek keeps working, and otherwise proposes a new index beside it.

Why does a column inside YEAR() get no arc? No index on the column can seek on an expression over it. Written as a range on the bare column, the same test can be sought.

Does the report change anything? No. Scripts are copied to the clipboard for you to read and run yourself.