<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 43 of 54 17 minAdvanced

Clustered indexes

The clustered index is the table itself, sorted by its key. Seeks and range scans on the key, why ORDER BY on the key is free, how to choose a clustering key, page splits and fragmentation, and rebuilding with a fill factor.

The last lesson ended with a clustered index on SaleId. Its leaf level holds the whole rows, in key order. That is the defining feature of a clustered index: it does not point to the table, it is the table. Because rows can only be stored in one order, a table can have only one clustered index.

This script rebuilds dbo.Sales, this time with a primary key, so you can start here:

Create dbo.Sales with a primary key
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); SELECT name, type_desc, is_unique, is_primary_keyFROM sys.indexesWHERE object_id = OBJECT_ID(N'dbo.Sales');
Output
name      type_desc  is_unique  is_primary_key
--------  ---------  ---------  --------------
PK_Sales  CLUSTERED  1          1

A PRIMARY KEY creates a clustered index unless the table already has one or you write PRIMARY KEY NONCLUSTERED. That is why every BuildMart table is already clustered on its key. Those indexes have names like PK__Products__B40CC6CD…, generated by SQL Server; naming the constraint, as here, gives the index a name you can use in scripts.

Seeks and range scans on the key

Query.sql
SET STATISTICS IO ON;SELECT SaleDate, Amount FROM dbo.Sales WHERE SaleId = 777; SELECT COUNT(*) AS SalesRows, SUM(Amount) AS TotalFROM dbo.SalesWHERE SaleId BETWEEN 10001 AND 12000;SET STATISTICS IO OFF;
Output
SaleDate    Amount
----------  ------
2022-09-11   61.87

Table 'Sales'. Scan count 0, logical reads 3, 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     Total
---------  --------
     2000  92290.00

Table 'Sales'. Scan count 1, logical reads 40, 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.

One row costs one read per level of the tree. 2,000 neighbouring rows cost the trip down plus the leaf pages that hold them, about 2,000 ÷ 56.

ORDER BY on the key is free

The leaf level is already in SaleId order, so SQL Server can return rows in that order just by reading along the chain. Sorting on any other column needs a Sort step. SET SHOWPLAN_TEXT ON shows the plan SQL Server would use instead of running the query; the next lessons look at plans in detail.

Query.sql
SET SHOWPLAN_TEXT ON;GOSELECT TOP (5) SaleId, Amount FROM dbo.Sales ORDER BY SaleId;SELECT TOP (5) SaleId, Amount FROM dbo.Sales ORDER BY Amount DESC;GOSET SHOWPLAN_TEXT OFF;
Output
StmtText
-------------------------------------------------------------
SELECT TOP (5) SaleId, Amount FROM dbo.Sales ORDER BY SaleId;

StmtText
---------------------------------------------------------------------------------------------------
  |--Top(TOP EXPRESSION:((5)))
       |--Clustered Index Scan(OBJECT:([BuildMart].[dbo].[Sales].[PK_Sales]), ORDERED FORWARD)

StmtText
------------------------------------------------------------------
SELECT TOP (5) SaleId, Amount FROM dbo.Sales ORDER BY Amount DESC;

StmtText
----------------------------------------------------------------------------------
  |--Sort(TOP 5, ORDER BY:([BuildMart].[dbo].[Sales].[Amount] DESC))
       |--Clustered Index Scan(OBJECT:([BuildMart].[dbo].[Sales].[PK_Sales]))

The first plan reads the first rows of the index in order (ORDERED FORWARD) and stops after five. The second must read all 100,000 rows to find the five largest amounts, through a Top N Sort (the Sort(TOP 5, …) line), which keeps only the five largest it has seen so far.

Other columns still scan

The tree is sorted by SaleId only. A search on any other column cannot use it to seek:

Query.sql
SET STATISTICS IO ON;SELECT COUNT(*) AS SalesRows, SUM(Amount) AS TotalFROM dbo.SalesWHERE SaleDate = '2023-06-01';SET STATISTICS IO OFF;
Output
SalesRows    Total
---------  -------
       91  4269.76

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.

Every page is read again. The next lesson adds nonclustered indexes for searches like this; first, the question of which column the clustered index should be on.

Choosing the clustering key

PropertyWhy it matters
NarrowThe clustering key is copied into every nonclustered index row (next lesson). A 4-byte int costs far less than a 16-byte GUID or a 50-character name.
UniqueIf the key is not unique, SQL Server adds a hidden 4-byte uniquifier to duplicate rows to tell them apart.
StaticChanging a key value moves the row to another place in the tree and changes every nonclustered index that holds it.
Ever-increasingNew rows go at the end of the last page, so no page has to be split to make room in the middle.

An int or bigint IDENTITY column has all four, which is why it is the usual choice. An email address fails on narrow and static; a random GUID fails on narrow and ever-increasing.

Clustering on something else can still be right when most queries read ranges of it. A reporting table that is almost always read by date can be clustered on the date. Compare a copy of Sales clustered on SaleDate:

Query.sql
DROP TABLE IF EXISTS dbo.SalesByDate;SELECT * INTO dbo.SalesByDate FROM dbo.Sales;CREATE CLUSTERED INDEX CX_SalesByDate ON dbo.SalesByDate (SaleDate); SET STATISTICS IO ON;SELECT COUNT(*) AS SalesRows, SUM(Amount) AS TotalFROM dbo.SalesByDateWHERE SaleDate >= '2023-06-01' AND SaleDate < '2023-07-01';SET STATISTICS IO OFF; SELECT OBJECT_NAME(object_id) AS TableName, page_count,       avg_record_size_in_bytes AS AvgRowBytesFROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'DETAILED')WHERE object_id IN (OBJECT_ID(N'dbo.Sales'), OBJECT_ID(N'dbo.SalesByDate'))  AND index_level = 0;
Output
SalesRows      Total
---------  ---------
     2737  125870.80

Table 'SalesByDate'. Scan count 1, logical reads 55, 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.

TableName    page_count  AvgRowBytes
-----------  ----------  -----------
Sales              1786        142.4
SalesByDate        1887      149.356

A month of sales now costs a few dozen reads instead of a full scan. The price shows in the second result: SaleDate is not unique (about 91 sales a day), so most rows carry a uniquifier and the rows are a little longer. Writing CREATE UNIQUE CLUSTERED INDEX … (SaleDate, SaleId) avoids the uniquifier and gives the same range seeks.

Page splits

A clustered index keeps its rows in key order, so each new row has one correct page. If that page is full, SQL Server splits it: it allocates a new page, moves about half the rows there and links it into the chain. A split costs extra writes and log records, and it leaves two half-empty pages. The new page is usually far away in the file, so the chain no longer runs in file order: the index is fragmented.

Interactive

Page splits

714213101724613202916235121918152241118
Leaf pages in key order (the number is the page’s place in the file)
page 1
1 pages
0 splits
0% average fullness
0% out of order
Keys in a scrambled order, like NEWID() values. Each row must go to its place in key order.

Here is the same effect in SQL Server. Two tables get the same 10,000 keys, inserted one row at a time: one table in ascending order, the other in a scrambled order. Each row has a 400-byte filler so that pages fill up quickly.

Query.sql
DROP TABLE IF EXISTS dbo.SeqKeys, dbo.RandKeys;CREATE TABLE dbo.SeqKeys  (Id int NOT NULL CONSTRAINT PK_SeqKeys  PRIMARY KEY, Filler char(400) NOT NULL DEFAULT '');CREATE TABLE dbo.RandKeys (Id int NOT NULL CONSTRAINT PK_RandKeys PRIMARY KEY, Filler char(400) NOT NULL DEFAULT ''); SET NOCOUNT ON;DECLARE @n int = 1;BEGIN TRANSACTION;WHILE @n <= 10000BEGIN    INSERT INTO dbo.SeqKeys (Id)  VALUES (@n);                 -- 1, 2, 3, …    INSERT INTO dbo.RandKeys (Id) VALUES (@n * 7919 % 10007);  -- 7919, 5831, 3743, …    SET @n += 1;END;COMMIT; SELECT OBJECT_NAME(object_id) AS TableName, page_count,       CAST(avg_page_space_used_in_percent AS decimal(5,1)) AS PctFull,       CAST(avg_fragmentation_in_percent AS decimal(5,1)) AS PctFragmentedFROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'DETAILED')WHERE object_id IN (OBJECT_ID(N'dbo.SeqKeys'), OBJECT_ID(N'dbo.RandKeys'))  AND index_level = 0;
Output
TableName  page_count  PctFull  PctFragmented
---------  ----------  -------  -------------
SeqKeys           527     96.8            0.2
RandKeys          747     68.3           99.1

The ascending keys always landed at the end of the last page, so the pages are packed full and in order. The scrambled keys caused splits all over the index: more pages, each only partly full, and most of them out of order. Every scan of that table reads more pages than it needs to.

Rebuilding an index, and fill factor

ALTER INDEX … REBUILD builds the index again from scratch: pages packed in key order, with no fragmentation. REORGANIZE is a lighter option that shuffles and compacts the leaf pages in place.

Query.sql
ALTER INDEX PK_RandKeys ON dbo.RandKeys REBUILD; SELECT OBJECT_NAME(object_id) AS TableName, page_count,       CAST(avg_page_space_used_in_percent AS decimal(5,1)) AS PctFull,       CAST(avg_fragmentation_in_percent AS decimal(5,1)) AS PctFragmentedFROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID(N'dbo.RandKeys'), NULL, NULL, 'DETAILED')WHERE index_level = 0;
Output
TableName  page_count  PctFull  PctFragmented
---------  ----------  -------  -------------
RandKeys          527     96.8            0.0

A rebuild can also leave room on each page with FILLFACTOR: ALTER INDEX PK_RandKeys ON dbo.RandKeys REBUILD WITH (FILLFACTOR = 80) fills leaf pages to 80%, so the next random inserts find space without splitting. The cost is 25% more pages for every scan to read. Use it only on indexes that really receive inserts in the middle; an IDENTITY key should stay at 100%.

Quick check

How many clustered indexes can a table have?

Quick check

Why is an ever-increasing IDENTITY a good clustering key for a table with many inserts?

Practice

Make a copy of Sales named dbo.SalesByMonth with a UNIQUE clustered index on (SaleDate, SaleId). With STATISTICS IO on, count the sales and add up Amount for March 2023 on both dbo.Sales and dbo.SalesByMonth, and compare the reads. Drop the copy at the end.

Show a hint

SELECT * INTO creates the copy as a heap; then CREATE UNIQUE CLUSTERED INDEX. Use a half-open range: SaleDate >= '2023-03-01' AND SaleDate < '2023-04-01'.

Show the solution
Solution.sql
DROP TABLE IF EXISTS dbo.SalesByMonth;SELECT * INTO dbo.SalesByMonth FROM dbo.Sales;CREATE UNIQUE CLUSTERED INDEX CX_SalesByMonth ON dbo.SalesByMonth (SaleDate, SaleId); SET STATISTICS IO ON;SELECT COUNT(*) AS SalesRows, SUM(Amount) AS TotalFROM dbo.SalesWHERE SaleDate >= '2023-03-01' AND SaleDate < '2023-04-01'; SELECT COUNT(*) AS SalesRows, SUM(Amount) AS TotalFROM dbo.SalesByMonthWHERE SaleDate >= '2023-03-01' AND SaleDate < '2023-04-01';SET STATISTICS IO OFF; DROP TABLE dbo.SalesByMonth;
Output
SalesRows      Total
---------  ---------
     2829  130188.01

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.

SalesRows      Total
---------  ---------
     2829  130188.01

Table 'SalesByMonth'. Scan count 1, logical reads 55, 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 clustered index’s leaf level is the table: full rows in key order. One per table.
  • A PRIMARY KEY is clustered by default. Name your constraints so the index has a usable name.
  • Seeks and range seeks on the clustering key are cheap, and ORDER BY on the key needs no sort.
  • A good clustering key is narrow, unique, static and ever-increasing: an IDENTITY int or bigint.
  • Inserting into the middle of full pages causes page splits: half-empty pages out of order.
  • REBUILD repacks an index; FILLFACTOR leaves free space, at the cost of more pages to read.
0 of 54 lessons completed