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

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.

Until now you have told SQL Server what you want. This module is about how it finds it, and why one query takes a millisecond while another, returning the same rows, takes seconds. Almost all of the difference comes from one number: how many pages the query has to read.

The BuildMart tables are far too small to show this: each one fits on a single page. The script below creates 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.

Create dbo.Sales (100,000 rows)
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;
Output
SalesRows  FirstDay    LastDay
---------  ----------  ----------
   100000  2022-01-01  2024-12-31

The data has a pattern you can rely on later: 5,000 customers with 20 sales each, 12 products, dates from 2022-01-01 to 2024-12-31, and three channels (Web 70%, Shop 20%, Phone 10%).

Pages: the unit SQL Server reads

SQL Server stores every table in pages of 8 KB (8,192 bytes). A page starts with a 96-byte header, then the rows, and ends with a small slot array: 2 bytes per row, saying where on the page each row starts. That leaves about 8,060 bytes for rows. A row must fit on one page (long varchar(max) values are stored on separate pages).

SQL Server reads and writes whole pages, never single rows. To read one row, it reads the page the row is on. sys.dm_db_index_physical_stats shows how many pages a table uses:

Query.sql
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');
Output
index_type_desc  page_count  record_count  AvgRowBytes
---------------  ----------  ------------  -----------
HEAP                   1786        100000        142.4

Each row takes about 142 bytes, plus 2 bytes in the slot array. 8,060 ÷ 144 is 56 rows per page, and 100,000 ÷ 56 is 1,786 pages: about 14 MB.

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:

Query.sql
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;
Output
TableName  page_count  AvgRowBytes
---------  ----------  -----------
Sales            1786        142.4
SalesSlim         550         42.4

The same 100,000 rows fit on less than a third as many pages. Every query that reads the whole table does that much less work, and more of the table fits in memory. Choosing the narrowest type that fits the data is a performance decision, not only a tidy one.

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.

Logical reads is the number to watch when you tune a query: it measures the work itself and is the same every time you run the query. Find one sale:

Query.sql
SET STATISTICS IO ON;SELECT SaleId, SaleDate, CustomerId, AmountFROM dbo.SalesWHERE SaleId = 4321;SET STATISTICS IO OFF;
Output
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.

One row, and 1,786 pages read: every page of the table. The table is a heap, a table without a clustered index. Its rows are stored wherever there was space, in no order, so SQL Server cannot know which page holds SaleId 4321. The only way to find it is to read every page and check every row. The same is true for any WHERE on a heap.

B-trees: finding a row without reading them all

An index keeps rows sorted by a key and builds a tree of pages above them, so SQL Server can go straight to the right page. Create a clustered index on SaleId and look at the pages again:

Query.sql
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;
Output
index_type_desc  index_level  page_count  record_count
---------------  -----------  ----------  ------------
CLUSTERED INDEX            2           1             3
CLUSTERED INDEX            1           3          1786
CLUSTERED INDEX            0        1786        100000

The index has three levels:

  • 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.

Together these form a B-tree (balanced tree): every leaf page is the same distance from the root. Now find the same sale again:

Query.sql
SET STATISTICS IO ON;SELECT SaleId, SaleDate, CustomerId, AmountFROM dbo.SalesWHERE SaleId = 4321;SET STATISTICS IO OFF;
Output
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.

Three logical reads instead of 1,786: the root, one intermediate page and one leaf page. Reading down the tree like this is called a seek. Reading all the leaf pages is a scan. (Scan count 0 in the report means the index was entered once, for a single-value seek.)

Interactive

Finding one sale

Level 2: root page (1 page, 3 entries)
134,83369,665
Level 1: intermediate pages (3 pages, one entry per leaf page)
Page 11–34,832622 leaf pages
Page 234,833–69,664622 leaf pages
Page 369,665–100,000542 leaf pages
Level 0: leaf pages (1,786 pages holding the rows)
Leaf 774,257–4,31256 rows
Leaf 784,313–4,36856 rows
Leaf 794,369–4,42456 rows
3 logical reads: the root, intermediate page 1, and leaf page 78.
Each page on the way down is sorted, so SQL Server knows which entry to follow without reading the others. Finding any of the 100,000 rows costs the same 3 reads, one per level. Neighbouring leaf pages are linked (⇄), which is how range queries read on.

Trees stay shallow as tables grow, because each index page holds hundreds of entries. Each extra level multiplies the number of rows the tree can reach by several hundred, so even tables with billions of rows usually need only four or five levels, which means four or five reads to find one row.

Ranges follow the leaf chain

Each leaf page also stores the address of the page before and after it, so the leaf level is a linked chain in key order. For a range, SQL Server seeks to the first row and then reads along the chain until it passes the end of the range:

Query.sql
SET STATISTICS IO ON;SELECT COUNT(*) AS SalesRows, SUM(Amount) AS TotalFROM dbo.SalesWHERE SaleId BETWEEN 1001 AND 2000;SET STATISTICS IO OFF;
Output
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.

1,000 rows at 56 rows a page is about 18 leaf pages, plus the root and an intermediate page on the way down. The cost of a range seek grows with the number of rows it returns, not with the size of the table.

Quick check

A query reads 3 rows from a table of 50,000 pages and reports 50,000 logical reads. What is the most likely reason?

Quick check

Why is logical reads a better measure than elapsed time when you compare two versions of a query?

Practice

With STATISTICS IO on, find the sales with SaleId 10, 50000 and 99999 in one query (WHERE SaleId IN …), returning SaleId and Amount. Before you run it, predict the scan count and the logical reads.

Show a hint

Each value is a separate seek down the 3-level tree.

Show the solution
Solution.sql
SET STATISTICS IO ON;SELECT SaleId, AmountFROM dbo.SalesWHERE SaleId IN (10, 50000, 99999);SET STATISTICS IO OFF;
Output
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.
0 of 54 lessons completed