<style> .reveal-up, .reveal-left, .reveal-right, .reveal-stagger > * { opacity: 1 !important; transform: none !important; } </style>
  1. Learn
  2. SQL
  3. Indexes and performance
All lessons in this course

Lesson 46 of 54 18 minAdvanced

Sargable queries and statistics

Why a WHERE that wraps the column in a function cannot use an index, how to rewrite it, implicit conversions, and the statistics the optimizer uses to estimate rows: histograms, automatic updates, and what stale statistics do to a plan.

An index only helps if SQL Server can use it to seek. Whether it can depends on how you write the WHERE. A predicate that can use an index seek is called sargable, from “Search ARGument ABLE”. Start from a fresh table with three nonclustered indexes:

Create dbo.Sales with three indexes
DROP TABLE IF EXISTS dbo.Sales;CREATE TABLE dbo.Sales (    SaleId     int           NOT NULL CONSTRAINT PK_Sales PRIMARY KEY,    SaleDate   date          NOT NULL,    CustomerId int           NOT NULL,    ProductId  int           NOT NULL,    Quantity   int           NOT NULL,    Amount     decimal(10,2) NOT NULL,    Channel    varchar(10)   NOT NULL,    Notes      char(100)     NULL);INSERT INTO dbo.Sales (SaleId, SaleDate, CustomerId, ProductId, Quantity, Amount, Channel)SELECT value,       DATEADD(day, value * 37 % 1096, '2022-01-01'),       value * 7919 % 5000 + 1,       value % 12 + 1,       value % 7 + 1,       value * 31 % 9000 / 100.0 + 1,       CASE value % 10 WHEN 0 THEN 'Phone' WHEN 1 THEN 'Shop' WHEN 2 THEN 'Shop' ELSE 'Web' ENDFROM GENERATE_SERIES(1, 100000); CREATE INDEX IX_Sales_SaleDate ON dbo.Sales (SaleDate);CREATE INDEX IX_Sales_Channel  ON dbo.Sales (Channel);CREATE INDEX IX_Sales_Amount   ON dbo.Sales (Amount);

Keep the column alone

Both queries below count the sales of June 2023. The first wraps SaleDate in functions; the second compares the bare column with a range:

Query.sql
SET STATISTICS IO ON;SELECT COUNT(*) AS SalesRows FROM dbo.SalesWHERE YEAR(SaleDate) = 2023 AND MONTH(SaleDate) = 6; SELECT COUNT(*) AS SalesRows FROM dbo.SalesWHERE SaleDate >= '2023-06-01' AND SaleDate < '2023-07-01';SET STATISTICS IO OFF;
Output
SalesRows
---------
     2737

Table 'Sales'. Scan count 1, logical reads 163, physical reads 1, page server reads 0, read-ahead reads 161, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.

SalesRows
---------
     2737

Table 'Sales'. Scan count 1, logical reads 8, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.

Same answer, very different work. The index is sorted by SaleDate, not by YEAR(SaleDate). To find the rows where a function of the column has some value, SQL Server must compute the function for every row, so it scans the whole index. With the bare column, it seeks to 2023-06-01 and stops at 2023-07-01.

The rule: do the arithmetic on the other side. Leave the indexed column alone and move the calculation to the constant or parameter:

Not sargableSargable rewrite
WHERE YEAR(SaleDate) = 2023WHERE SaleDate >= '2023-01-01' AND SaleDate < '2024-01-01'
WHERE DATEADD(day, 30, SaleDate) > GETDATE()WHERE SaleDate > DATEADD(day, -30, GETDATE())
WHERE Amount * 1.2 > 60WHERE Amount > 50
WHERE LEFT(Name, 3) = N'Cem'WHERE Name LIKE N'Cem%'
WHERE ISNULL(City, N'') = N'Leeds'WHERE City = N'Leeds'
WHERE CONVERT(char(8), SaleDate, 112) = '20230601'WHERE SaleDate = '2023-06-01'
Interactive

Same rows, different work

Not sargable
SELECT COUNT(*) FROM dbo.Sales
WHERE YEAR(SaleDate) = 2023;
Index ScanIX_Sales_SaleDate
163 logical reads
33,304 rows counted
Sargable
SELECT COUNT(*) FROM dbo.Sales
WHERE SaleDate >= '2023-01-01'
  AND SaleDate <  '2024-01-01';
Index SeekIX_Sales_SaleDate
57 logical reads
33,304 rows counted
YEAR() must be worked out for every row before it can be compared. The range compares the column as it is stored, so SQL Server seeks to the first day and stops after the last. Here the sargable version reads 106 fewer pages, about 3 times less.

LIKE and leading wildcards

LIKE 'Sh%' is a range: everything from “Sh” up to just before “Si”, so it can seek. LIKE '%op' can match anywhere in the sort order, so every row must be checked:

Query.sql
SET STATISTICS IO ON;SELECT COUNT(*) AS SalesRows FROM dbo.Sales WHERE Channel LIKE 'Sh%';SELECT COUNT(*) AS SalesRows FROM dbo.Sales WHERE Channel LIKE '%op';SET STATISTICS IO OFF;
Output
SalesRows
---------
    20000

Table 'Sales'. Scan count 1, logical reads 48, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.

SalesRows
---------
    20000

Table 'Sales'. Scan count 1, logical reads 218, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.

Implicit conversions

Channel is varchar. Compare it with an nvarchar value (with the N prefix) and the two types do not match. nvarchar has the higher data type precedence, so SQL Server converts the column, row by row:

Query.sql
SET STATISTICS IO ON;SELECT COUNT(*) AS SalesRows FROM dbo.Sales WHERE Channel = 'Phone';SELECT COUNT(*) AS SalesRows FROM dbo.Sales WHERE Channel = N'Phone';SET STATISTICS IO OFF;GOSET SHOWPLAN_TEXT ON;GOSELECT COUNT(*) AS SalesRows FROM dbo.Sales WHERE Channel = N'Phone';GOSET SHOWPLAN_TEXT OFF;
Output
SalesRows
---------
    10000

Table 'Sales'. Scan count 1, logical reads 26, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.

SalesRows
---------
    10000

Table 'Sales'. Scan count 1, logical reads 218, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.

StmtText
---------------------------------------------------------------------
SELECT COUNT(*) AS SalesRows FROM dbo.Sales WHERE Channel = N'Phone';

StmtText
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  |--Compute Scalar(DEFINE:([Expr1002]=CONVERT_IMPLICIT(int,[Expr1006],0)))
       |--Stream Aggregate(DEFINE:([Expr1006]=Count(*)))
            |--Index Scan(OBJECT:([BuildMart].[dbo].[Sales].[IX_Sales_Channel]),  WHERE:(CONVERT_IMPLICIT(nvarchar(10),[BuildMart].[dbo].[Sales].[Channel],0)=N'Phone'))

The plan shows CONVERT_IMPLICIT(nvarchar(10), [Channel], 0) inside a scan. This happens often with applications: .NET strings are sent as nvarchar by default, so a parameter compared with a varchar column can silently turn every seek into a scan. Match parameter types to column types.

Statistics: what the optimizer knows

To choose between a seek with lookups and a scan, the optimizer needs to know how many rows a predicate will return before it runs. It gets that from statistics: a summary of the values in a column, kept for every index and for other columns used in WHERE clauses.

The core of a statistic is a histogram of the first key column, with up to 200 steps. Channel has only three values, so its histogram has three steps:

Query.sql
DBCC SHOW_STATISTICS (N'dbo.Sales', IX_Sales_Channel) WITH HISTOGRAM;
Output
RANGE_HI_KEY  RANGE_ROWS  EQ_ROWS  DISTINCT_RANGE_ROWS  AVG_RANGE_ROWS
------------  ----------  -------  -------------------  --------------
Phone                  0    10000                    0               1
Shop                   0    20000                    0               1
Web                    0    70000                    0               1

DBCC execution completed. If DBCC printed error messages, contact your system administrator.
  • RANGE_HI_KEY: the top value of the step.
  • EQ_ROWS: rows equal to that value.
  • RANGE_ROWS and DISTINCT_RANGE_ROWS: rows (and distinct values) between this step and the one before.
  • AVG_RANGE_ROWS: the estimate for one value inside the range.

For Channel = 'Phone' the optimizer reads 10,000 straight from the histogram: far too many for lookups. For a date column with 1,096 values, each histogram step covers a range of days, and the estimate for one day inside a step is that step’s average. sys.dm_db_stats_properties shows how big each statistic is and how much the table has changed since it was built:

Query.sql
SELECT s.name AS StatName, p.rows, p.rows_sampled, p.steps, p.modification_counterFROM sys.stats AS sCROSS APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) AS pWHERE s.object_id = OBJECT_ID(N'dbo.Sales')ORDER BY s.stats_id;
Output
StatName             rows  rows_sampled  steps  modification_counter
-----------------  ------  ------------  -----  --------------------
PK_Sales             NULL          NULL   NULL                  NULL
IX_Sales_SaleDate  100000        100000    200                     0
IX_Sales_Channel   100000        100000      3                     0
IX_Sales_Amount    100000        100000      3                     0

Statistics on an index are built with a full scan when the index is created. PK_Sales shows NULLs because its index was created on the empty table, before the INSERT; its statistics will be filled in the first time a query needs them. modification_counter counts the changed rows since then. When enough rows have changed, SQL Server updates the statistics automatically the next time a query needs them. On a table this size the threshold is about √(1,000 × rows): 10,000 changes for 100,000 rows.

Stale statistics, bad plans

Now load a big batch: 5,000 sales on one new day, 2025-01-15. That is below the threshold, so the statistics are not updated, and the histogram still ends at 2024-12-31.

Query.sql
INSERT INTO dbo.Sales (SaleId, SaleDate, CustomerId, ProductId, Quantity, Amount, Channel)SELECT 100000 + value, '2025-01-15', value % 5000 + 1, value % 12 + 1, 1, 9.99, 'Web'FROM GENERATE_SERIES(1, 5000); SET STATISTICS IO ON;SELECT COUNT(*) AS SalesRows, SUM(Quantity) AS ItemsFROM dbo.SalesWHERE SaleDate = '2025-01-15';SET STATISTICS IO OFF;
Output
SalesRows  Items
---------  -----
     5000   5000

Table 'Sales'. Scan count 1, logical reads 15011, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.

Because the statistics know nothing about that day, the optimizer guessed about 91 rows, the average for one day, and chose a seek on IX_Sales_SaleDate with a key lookup per row (Quantity is not in the index). The query found 5,000 rows, so it paid for 5,000 lookups: several times the cost of reading the whole table. Update the statistics and run it again:

Query.sql
UPDATE STATISTICS dbo.Sales IX_Sales_SaleDate WITH FULLSCAN; SET STATISTICS IO ON;SELECT COUNT(*) AS SalesRows, SUM(Quantity) AS ItemsFROM dbo.SalesWHERE SaleDate = '2025-01-15';SET STATISTICS IO OFF;
Output
SalesRows  Items
---------  -----
     5000   5000

Table 'Sales'. Scan count 1, logical reads 1883, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.

With the right estimate, the optimizer chose a scan, the cheaper plan for 5,000 rows. Updating statistics also makes the plans that depend on them compile again. Most systems update statistics in a nightly maintenance job as well as automatically, and tables that receive large loads benefit from an UPDATE STATISTICS straight after the load.

Quick check

Which WHERE clause can seek an index on OrderDate?

Quick check

A query’s actual plan shows 1 estimated row and 40,000 actual rows on an index seek with key lookups. What is the first thing to check?

Practice

This query counts the sales of December 2024: SELECT COUNT(*) AS SalesRows FROM dbo.Sales WHERE DATEPART(year, SaleDate) = 2024 AND DATEPART(month, SaleDate) = 12;. Rewrite it to be sargable and compare the reads of both versions.

Show a hint

December 2024 is SaleDate >= '2024-12-01' AND SaleDate < '2025-01-01'.

Show the solution
Solution.sql
SET STATISTICS IO ON;SELECT COUNT(*) AS SalesRowsFROM dbo.SalesWHERE DATEPART(year, SaleDate) = 2024 AND DATEPART(month, SaleDate) = 12; SELECT COUNT(*) AS SalesRowsFROM dbo.SalesWHERE SaleDate >= '2024-12-01' AND SaleDate < '2025-01-01';SET STATISTICS IO OFF;
Output
SalesRows
---------
     2827

Table 'Sales'. Scan count 1, logical reads 171, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.

SalesRows
---------
     2827

Table 'Sales'. Scan count 1, logical reads 7, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.

Remember

  • A predicate is sargable when the column stands alone, so an index on it can be sought.
  • Functions and arithmetic on the column force a scan: move the calculation to the other side.
  • LIKE 'abc%' can seek; LIKE '%abc' cannot.
  • Comparing a varchar column with nvarchar converts the column (CONVERT_IMPLICIT), often turning a seek into a scan.
  • Statistics hold a histogram of up to 200 steps; the optimizer uses them to estimate rows and choose a plan.
  • Stale statistics, especially for new values beyond the histogram, cause bad estimates and bad plans. UPDATE STATISTICS fixes them.
0 of 54 lessons completed