Heap or Clustered Index: When a Heap Table Is the Right Choice

Heap or clustered index is a choice you make for every table, and a clustered index is the safer default.

A heap is not a mistake. It fits a few jobs well. It also has one habit, forwarded records, that quietly multiplies the reads of a scan. The next sections measure that habit and the two places where a heap wins.

Gouache painting of a basket with a heap of clothes beside neat folded stacks, a vermilion shirt on top

What a Heap Is

A heap is a table without a clustered index. SQL Server stores its rows on any page that has room, in no order. The only way to find a row is to scan every page, or to use a nonclustered index. That index points at the row with an 8 byte row identifier: the file, the page and the slot.

A clustered table stores its rows in the order of the key, and finds them by that key. The heap or clustered index choice decides how SQL Server finds every row.

The demo builds the same table twice. One copy is a heap, and the other has a clustered index on the ticket number. Each has 50,000 rows with an empty Details column. The first query reports the size and the forwarded records of the heap, and the reads of a full scan.

IF DB_ID(N'ForwardedHeapDemo') IS NULL CREATE DATABASE ForwardedHeapDemo;
GO
USE ForwardedHeapDemo;
GO
DROP TABLE IF EXISTS dbo.TicketsHeap;
DROP TABLE IF EXISTS dbo.TicketsClustered;
CREATE TABLE dbo.TicketsHeap (TicketID int NOT NULL, Title varchar(200) NOT NULL, Details varchar(2000) NULL);
CREATE TABLE dbo.TicketsClustered (TicketID int NOT NULL, Title varchar(200) NOT NULL, Details varchar(2000) NULL);
CREATE CLUSTERED INDEX CIX_TicketsClustered ON dbo.TicketsClustered (TicketID);
INSERT dbo.TicketsHeap (TicketID, Title, Details)
SELECT TOP (50000) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), 'Ticket', NULL FROM sys.all_columns AS a CROSS JOIN sys.all_columns AS b;
INSERT dbo.TicketsClustered (TicketID, Title, Details) SELECT TicketID, Title, Details FROM dbo.TicketsHeap;
SELECT N'Before' AS Moment, OBJECT_NAME(object_id) AS TableName, page_count, record_count, forwarded_record_count
FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID(N'dbo.TicketsHeap'), 0, NULL, 'DETAILED');
SET STATISTICS IO ON;
SELECT COUNT(*) AS Tickets FROM dbo.TicketsHeap;
SET STATISTICS IO OFF;
MomentTableNamepage_countrecord_countforwarded_record_count
BeforeTicketsHeap143500000

The heap holds 143 pages and no forwarded records. The Messages tab reports 143 logical reads for the count, so one read per page.

Where a Heap Hurts: Forwarded Records

Now give half of the tickets a longer Details text. A row that grows can outgrow its page. A clustered table splits the page. A heap moves the row to a page with room and leaves a forwarding pointer at the old place. The scan then visits the old slot and follows the pointer, so every moved row costs a second read. The script widens the same rows in both tables.

UPDATE dbo.TicketsHeap SET Details = REPLICATE('d', 400) WHERE TicketID % 2 = 0;
UPDATE dbo.TicketsClustered SET Details = REPLICATE('d', 400) WHERE TicketID % 2 = 0;
GO
SELECT OBJECT_NAME(object_id) AS TableName, index_type_desc, page_count, record_count, forwarded_record_count
FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'DETAILED')
WHERE OBJECT_NAME(object_id) LIKE N'Tickets%' AND index_level = 0;
SET STATISTICS IO ON;
SELECT COUNT(*) AS Tickets FROM dbo.TicketsHeap;
SELECT COUNT(*) AS Tickets FROM dbo.TicketsClustered;
SET STATISTICS IO OFF;
TableNameindex_type_descpage_countrecord_countforwarded_record_count
TicketsHeapHEAP14927429024290
TicketsClusteredCLUSTERED INDEX263150000NULL

The heap has 24,290 forwarded records. Its record count of 74,290 includes them, and the same 50,000 tickets now cost 25,782 reads. The clustered table has no forwarding and needs 2,642 reads. Its page splits left the pages half full, so it holds 2,631 pages. A rebuild fixes that, but the point stands: the heap pays 10 times more for the same count. The pictures come from a second server, which read 25,774 pages on the heap.

Actual plans of the two COUNT(*) statements after the rows were widened: a Table Scan on TicketsHeap and a Clustered Index Scan on TicketsClustered, each 50000 rows.

SSMS Messages tab with STATISTICS IO: 25774 logical reads on TicketsHeap and 2642 on TicketsClustered.

The numbers depend on how much a row grows. Any heap whose rows grow after the insert collects forwarded records.

Find the Forwarded Records

A cursor is not needed to find the heaps that need a rebuild. This query lists every heap of the current database with forwarded records and writes the fix statement for each. It does not run anything. The SAMPLED mode counts forwarded records, and it reads fewer pages than DETAILED does on a big table. The query still reads every index of the database, so run it in a quiet window on a big database. To look at one table, pass its object id and 0.

SELECT OBJECT_SCHEMA_NAME(ps.object_id) AS SchemaName, OBJECT_NAME(ps.object_id) AS TableName, ps.page_count, ps.record_count, ps.forwarded_record_count,
       CAST(100.0 * ps.forwarded_record_count / NULLIF(ps.record_count, 0) AS decimal(5,1)) AS ForwardedPct,
       CONCAT(N'ALTER TABLE ', QUOTENAME(OBJECT_SCHEMA_NAME(ps.object_id)), N'.', QUOTENAME(OBJECT_NAME(ps.object_id)), N' REBUILD;') AS FixStatement
FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'SAMPLED') AS ps
WHERE ps.index_id = 0 AND ps.forwarded_record_count > 0;
SchemaNameTableNamepage_countrecord_countforwarded_record_countForwardedPctFixStatement
dboTicketsHeap1492742902429032.7ALTER TABLE [dbo].[TicketsHeap] REBUILD;

A rebuild of a heap rewrites the whole table. It also rebuilds every nonclustered index on it, because the row identifiers change. Run it in a quiet window on a big table.

ALTER TABLE dbo.TicketsHeap REBUILD;
GO
SELECT index_type_desc, page_count, record_count, forwarded_record_count FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID(N'dbo.TicketsHeap'), 0, NULL, 'DETAILED');
SET STATISTICS IO ON;
SELECT COUNT(*) AS Tickets FROM dbo.TicketsHeap;
SET STATISTICS IO OFF;
index_type_descpage_countrecord_countforwarded_record_count
HEAP1389500000

The heap shrank from 1,492 to 1,389 pages, and the forwarded records are gone. The count reads 1,389 pages, against 25,782 before. The cure lasts only until rows grow again.

Quick card titled Heap or Clustered Index: Heap: rows are stored in no order. Heap: nonclustered indexes carry an 8 byte row id. Heap: widened rows leave forwarded records. Fix: ALTER TABLE REBUILD. Check: forwarded_record_count. Tip: Choose a clustered index unless you can say why not.

Ranges and Lookups Favor the Clustered Index

A heap has no order, so a range of ticket numbers needs a scan. The clustered table seeks to the start of the range and reads on.

SET STATISTICS IO ON;
SELECT COUNT(*) AS InRange FROM dbo.TicketsHeap WHERE TicketID BETWEEN 1000 AND 1100;
SELECT COUNT(*) AS InRange FROM dbo.TicketsClustered WHERE TicketID BETWEEN 1000 AND 1100;
SET STATISTICS IO OFF;

Both queries return 101 rows. The heap reads all 1,389 pages, and the clustered table reads 10. Sorted output, grouping on the key and range queries all work the same way. The key order saves a scan or a sort.

Where a Heap Wins

In the heap or clustered index decision, two cases favor the heap. A staging table takes a large load, is read once, and is emptied. Nothing needs the rows in order, and nothing updates them in place. A heap suits that work, because nothing needs the rows in order.

The second case is a wide clustering key. A nonclustered index on a clustered table carries the clustering key in every row. On a heap it carries the 8 byte row identifier. The next script builds both cases with a 36 character key and an index on City.

DROP TABLE IF EXISTS dbo.WideHeap;
DROP TABLE IF EXISTS dbo.WideClustered;
CREATE TABLE dbo.WideHeap (Code char(36) NOT NULL, City varchar(30) NOT NULL);
CREATE TABLE dbo.WideClustered (Code char(36) NOT NULL PRIMARY KEY CLUSTERED, City varchar(30) NOT NULL);
INSERT dbo.WideHeap (Code, City)
SELECT TOP (100000) CONVERT(char(36), NEWID()), 'City' + CAST(ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 500 AS varchar(10)) FROM sys.all_columns AS a CROSS JOIN sys.all_columns AS b;
INSERT dbo.WideClustered (Code, City) SELECT Code, City FROM dbo.WideHeap;
CREATE INDEX IX_WideHeap_City ON dbo.WideHeap (City);
CREATE INDEX IX_WideClustered_City ON dbo.WideClustered (City);
GO
SELECT OBJECT_NAME(i.object_id) AS TableName, i.name AS IndexName, ps.page_count
FROM sys.indexes AS i CROSS APPLY sys.dm_db_index_physical_stats(DB_ID(), i.object_id, i.index_id, NULL, 'LIMITED') AS ps
WHERE i.name LIKE N'IX[_]Wide%' ORDER BY TableName;
TableNameIndexNamepage_count
WideClusteredIX_WideClustered_City655
WideHeapIX_WideHeap_City307

The same index takes 307 pages on the heap and 655 on the clustered table. With a narrow integer key the difference reverses. A 4 byte key is smaller than the 8 byte row identifier. A wide key is the case where a heap saves space.

You could argue that a heap also loads faster, because it keeps no order. That is plausible. Test your own load before you rely on it. The measurements here show the cost on the read side.

What to Remember

Choose a clustered index unless you can say why a heap fits. A heap fits staging tables and tables with a wide clustering key. Check heaps for forwarded records, and rebuild the ones where the share is high. The heap or clustered index choice is cheap to make once and costly to undo on a big table.

When you finish, run the cleanup script.

USE master;
GO
IF DB_ID(N'ForwardedHeapDemo') IS NOT NULL
BEGIN
    ALTER DATABASE ForwardedHeapDemo SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
    DROP DATABASE ForwardedHeapDemo;
END;

A heap is not a bad table, it is a table that has to earn the missing index.

Published by Pinal Dave on SQLAuthority. More of my work at pinaldave.com.


Discover more from SQL Authority with Pinal Dave

Subscribe to get the latest posts sent to your email.

Clustered Index, SQL Data Storage, SQL Heap, SQL Index
Previous Post
Configuring Parallel Index Operations in SQL Server
Next Post
Exact Vector Search Cost: Filtering Before VECTOR_DISTANCE

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.