How SQL Server stores data: pages, heaps and B-trees
What is underneath a table: 8 KB pages, rows per page, logical reads, heaps that must be read from end to end, and the B-tree that finds one row in three reads.
dbo.Sales, with 100,000 made-up sales. GENERATE_SERIES returns the numbers 1 to 100,000, and arithmetic on each number fills the columns. Note that the table has no primary key yet; that is on purpose.
DROP TABLE IF EXISTS dbo.Sales;CREATE TABLE dbo.Sales ( SaleId int NOT NULL, 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 COUNT(*) AS SalesRows, MIN(SaleDate) AS FirstDay, MAX(SaleDate) AS LastDayFROM dbo.Sales;SalesRows FirstDay LastDay --------- ---------- ---------- 100000 2022-01-01 2024-12-31
Pages: the unit SQL Server reads
varchar(max) values are stored on separate pages).
sys.dm_db_index_physical_stats shows how many pages a table uses:
SELECT index_type_desc, page_count, record_count, avg_record_size_in_bytes AS AvgRowBytesFROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID(N'dbo.Sales'), NULL, NULL, 'DETAILED');index_type_desc page_count record_count AvgRowBytes --------------- ---------- ------------ ----------- HEAP 1786 100000 142.4
Wider rows, more pages
Notes is char(100). A char column always takes its full length, even when it holds NULL, so every row carries 100 bytes it does not use. Compare a copy of the table where Notes is varchar(100), which stores only what is there:
DROP TABLE IF EXISTS dbo.SalesSlim;SELECT SaleId, SaleDate, CustomerId, ProductId, Quantity, Amount, Channel, CAST(Notes AS varchar(100)) AS NotesINTO dbo.SalesSlimFROM dbo.Sales; 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.SalesSlim')); DROP TABLE dbo.SalesSlim;TableName page_count AvgRowBytes --------- ---------- ----------- Sales 1786 142.4 SalesSlim 550 42.4
Logical reads: how much work a query did
SET STATISTICS IO ON makes SQL Server report, for each table a query touched, how many pages it read. In SSMS the report appears on the Messages tab.
logical reads: pages read from memory (the buffer pool). Every page a query uses counts here, whether or not it had to come from disk first. physical reads and read-ahead reads: pages that first had to be fetched from disk. They depend on what happens to be in memory, so they change from run to run. Scan count: how many times the table or index was entered.
SET STATISTICS IO ON;SELECT SaleId, SaleDate, CustomerId, AmountFROM dbo.SalesWHERE SaleId = 4321;SET STATISTICS IO OFF;SaleId SaleDate CustomerId Amount ------ ---------- ---------- ------ 4321 2024-08-15 3000 80.51 Table 'Sales'. Scan count 1, logical reads 1786, physical reads 283, page server reads 0, read-ahead reads 1711, 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.
B-trees: finding a row without reading them all
CREATE UNIQUE CLUSTERED INDEX CX_Sales ON dbo.Sales (SaleId); SELECT index_type_desc, index_level, page_count, record_countFROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID(N'dbo.Sales'), NULL, NULL, 'DETAILED')ORDER BY index_level DESC;index_type_desc index_level page_count record_count --------------- ----------- ---------- ------------ CLUSTERED INDEX 2 1 3 CLUSTERED INDEX 1 3 1786 CLUSTERED INDEX 0 1786 100000
Level 2, the root: one page with 3 entries, one for each page on the level below. Each entry holds the first SaleId on that page and the page’s address. Level 1, intermediate: 3 pages with 1,786 entries, one for each leaf page. Level 0, the leaf level: 1,786 pages holding the rows themselves, now sorted by SaleId.
SET STATISTICS IO ON;SELECT SaleId, SaleDate, CustomerId, AmountFROM dbo.SalesWHERE SaleId = 4321;SET STATISTICS IO OFF;SaleId SaleDate CustomerId Amount ------ ---------- ---------- ------ 4321 2024-08-15 3000 80.51 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.
Ranges follow the leaf chain
SET STATISTICS IO ON;SELECT COUNT(*) AS SalesRows, SUM(Amount) AS TotalFROM dbo.SalesWHERE SaleId BETWEEN 1001 AND 2000;SET STATISTICS IO OFF;SalesRows Total
--------- --------
1000 47925.00
Table 'Sales'. Scan count 1, logical reads 22, 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.Show a hint
Show the solution
SET STATISTICS IO ON;SELECT SaleId, AmountFROM dbo.SalesWHERE SaleId IN (10, 50000, 99999);SET STATISTICS IO OFF;SaleId Amount
------ ------
10 4.10
50000 21.00
99999 40.69
Table 'Sales'. Scan count 3, logical reads 9, 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
Tables are stored in 8 KB pages. SQL Server reads whole pages, so query cost is measured in pages. Narrower rows mean more rows per page and fewer pages to read. char(n) always uses n bytes. SET STATISTICS IO ON reports logical reads: the pages a query touched. It is the steadiest measure of a query’s work. A heap has no order: finding any row means scanning every page. An index is a B-tree: root, intermediate levels and leaf pages. A seek reads one page per level. Leaf pages are linked in key order, so a range seek reads only the pages that hold the range.