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.
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
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;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.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.
| Not sargable | Sargable rewrite |
|---|---|
WHERE YEAR(SaleDate) = 2023 | WHERE 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 > 60 | WHERE 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' |
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:
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;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
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:
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;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'))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
DBCC SHOW_STATISTICS (N'dbo.Sales', IX_Sales_Channel) WITH HISTOGRAM;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.
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:
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;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
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
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;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.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;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.UPDATE STATISTICS straight after the load.
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
Show the solution
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;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.