Nonclustered and covering indexes
Extra indexes for the searches the clustered index cannot answer: how a nonclustered index finds rows, key lookups, covering indexes with INCLUDE, the tipping point, column order in composite indexes, unique and filtered indexes, and what indexes cost.
dbo.Sales:
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);SET STATISTICS IO ON;SELECT COUNT(*) AS SalesRows, SUM(Amount) AS Spent, MAX(SaleDate) AS LastSaleFROM dbo.SalesWHERE CustomerId = 3000;SET STATISTICS IO OFF;SalesRows Spent LastSale
--------- ------- ----------
20 1000.20 2024-12-21
Table 'Sales'. Scan count 1, logical reads 1794, 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.CREATE INDEX IX_Sales_CustomerId ON dbo.Sales (CustomerId); SET STATISTICS IO ON;SELECT COUNT(*) AS SalesRows, SUM(Amount) AS Spent, MAX(SaleDate) AS LastSaleFROM dbo.SalesWHERE CustomerId = 3000;SET STATISTICS IO OFF;SalesRows Spent LastSale
--------- ------- ----------
20 1000.20 2024-12-21
Table 'Sales'. Scan count 1, logical reads 62, 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.CREATE INDEX makes a nonclustered index unless you say otherwise.
What a nonclustered index holds
SELECT i.name AS IndexName, s.index_level, s.page_countFROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID(N'dbo.Sales'), NULL, NULL, 'DETAILED') AS sJOIN sys.indexes AS i ON i.object_id = s.object_id AND i.index_id = s.index_idORDER BY i.index_id, s.index_level;IndexName index_level page_count ------------------- ----------- ---------- PK_Sales 0 1786 PK_Sales 1 6 PK_Sales 2 1 IX_Sales_CustomerId 0 174 IX_Sales_CustomerId 1 1
SET SHOWPLAN_TEXT ON;GOSELECT COUNT(*) AS SalesRows, SUM(Amount) AS Spent, MAX(SaleDate) AS LastSaleFROM dbo.SalesWHERE CustomerId = 3000;GOSET SHOWPLAN_TEXT OFF;StmtText
---------------------------------------------------------------------------------------------------------------------
SELECT COUNT(*) AS SalesRows, SUM(Amount) AS Spent, MAX(SaleDate) AS LastSale
FROM dbo.Sales
WHERE CustomerId = 3000;
StmtText
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|--Compute Scalar(DEFINE:([Expr1002]=CONVERT_IMPLICIT(int,[Expr1013],0), [Expr1003]=CASE WHEN [Expr1013]=(0) THEN NULL ELSE [Expr1014] END))
|--Stream Aggregate(DEFINE:([Expr1013]=Count(*), [Expr1014]=SUM([BuildMart].[dbo].[Sales].[Amount]), [Expr1004]=MAX([BuildMart].[dbo].[Sales].[SaleDate])))
|--Nested Loops(Inner Join, OUTER REFERENCES:([BuildMart].[dbo].[Sales].[SaleId]))
|--Index Seek(OBJECT:([BuildMart].[dbo].[Sales].[IX_Sales_CustomerId]), SEEK:([BuildMart].[dbo].[Sales].[CustomerId]=(3000)) ORDERED FORWARD)
|--Clustered Index Seek(OBJECT:([BuildMart].[dbo].[Sales].[PK_Sales]), SEEK:([BuildMart].[dbo].[Sales].[SaleId]=[BuildMart].[dbo].[Sales].[SaleId]) LOOKUP ORDERED FORWARD)LOOKUP on PK_Sales. Each lookup is a trip down the clustered index: about 3 reads per row, which explains most of the reads above.
Covering indexes: INCLUDE
INCLUDE list. They are stored in the leaf rows but are not part of the key.
CREATE INDEX IX_Sales_CustomerId ON dbo.Sales (CustomerId) INCLUDE (SaleDate, Amount) WITH (DROP_EXISTING = ON); SET STATISTICS IO ON;SELECT COUNT(*) AS SalesRows, SUM(Amount) AS Spent, MAX(SaleDate) AS LastSaleFROM dbo.SalesWHERE CustomerId = 3000;SET STATISTICS IO OFF;SalesRows Spent LastSale
--------- ------- ----------
20 1000.20 2024-12-21
Table 'Sales'. Scan count 1, logical reads 2, 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.DROP_EXISTING = ON replaces the index in one step. Now the 20 rows sit next to each other in the index leaf, with everything the query needs, so it costs the trip down and nothing else.
| Key columns | INCLUDE columns | |
|---|---|---|
The tipping point
Composite indexes: column order matters
CREATE INDEX IX_Sales_Product_Date ON dbo.Sales (ProductId, SaleDate); SET STATISTICS IO ON;-- 1. both columns: seek straight to product 4, June 2023SELECT COUNT(*) AS SalesRows FROM dbo.SalesWHERE ProductId = 4 AND SaleDate >= '2023-06-01' AND SaleDate < '2023-07-01'; -- 2. the first column alone: seek to product 4, read all its datesSELECT COUNT(*) AS SalesRows FROM dbo.SalesWHERE ProductId = 4; -- 3. the second column alone: June 2023 is spread through all 12 productsSELECT COUNT(*) AS SalesRows FROM dbo.SalesWHERE SaleDate >= '2023-06-01' AND SaleDate < '2023-07-01';SET STATISTICS IO OFF;SalesRows
---------
213
Table 'Sales'. Scan count 1, logical reads 4, 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
---------
8334
Table 'Sales'. Scan count 1, logical reads 21, 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
---------
2737
Table 'Sales'. Scan count 1, logical reads 213, 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.= first, then the column tested with a range.
Unique and filtered indexes
CREATE UNIQUE INDEX UX_Customers_Email ON dbo.Customers (Email) WHERE Email IS NOT NULL; INSERT INTO dbo.Customers (FirstName, LastName, City, Email, JoinedOn)VALUES (N'Hana', N'Ito', N'Leeds', NULL, '2024-03-01'); -- another NULL: fine INSERT INTO dbo.Customers (FirstName, LastName, City, Email, JoinedOn)VALUES (N'Ian', N'Ross', N'York', N'asha@example.com', '2024-03-02');Msg 2601, Level 14, State 1, Line 7 Cannot insert duplicate key row in object 'dbo.Customers' with unique index 'UX_Customers_Email'. The duplicate key value is (asha@example.com). The statement has been terminated.
WHERE Status = 'Pending'.
What indexes cost
SELECT i.name AS IndexName, u.user_seeks, u.user_scans, u.user_lookups, u.user_updatesFROM sys.indexes AS iLEFT JOIN sys.dm_db_index_usage_stats AS u ON u.object_id = i.object_id AND u.index_id = i.index_id AND u.database_id = DB_ID()WHERE i.object_id = OBJECT_ID(N'dbo.Sales');user_updates and no seeks, scans or lookups is paid for on every write and never used. It is a candidate for dropping.
SELECT COUNT(*) AS SalesRows, SUM(Amount) AS Total FROM dbo.Sales WHERE Channel = 'Phone' AND SaleDate >= '2024-06-01' AND SaleDate < '2024-07-01'. Create the index that lets it run with the fewest reads, then run it with STATISTICS IO on.
Show a hint
Show the solution
CREATE INDEX IX_Sales_Channel_Date ON dbo.Sales (Channel, SaleDate) INCLUDE (Amount); SET STATISTICS IO ON;SELECT COUNT(*) AS SalesRows, SUM(Amount) AS TotalFROM dbo.SalesWHERE Channel = 'Phone' AND SaleDate >= '2024-06-01' AND SaleDate < '2024-07-01';SET STATISTICS IO OFF;SalesRows Total
--------- --------
274 12684.40
Table 'Sales'. Scan count 1, logical reads 5, 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 nonclustered index is a separate B-tree: key columns plus the clustering key (or a RID on a heap). Columns the index lacks are fetched with key lookups, about three reads per row. INCLUDE adds columns to the leaf level so the index covers the query and needs no lookups. Past the tipping point (often around 1% of rows) lookups cost more than a scan, and the index is ignored. In a composite index, the leading column decides what can be sought: equality columns first, then a range. Unique and filtered indexes enforce rules and index subsets. Every index slows down writes, so index for real queries.