← SQL Server Internals

Indexes, Statistics, and Cardinality Estimation

▶ DRILL

Indexes

An index is a B+ tree. You have seen the diagrams. Let's make sure we agree on what they mean.

Root page → intermediate pages → leaf pages. In a clustered index, leaf pages are the data pages — rows are stored in key order. The clustered index is not a separate structure; it is the table. In a non-clustered index, leaf pages contain index keys plus a row locator (the clustered key, or a physical row ID if no clustered index exists). To retrieve columns not in the non-clustered index, SQL Server must seek the clustered index again — that's the key lookup, the expensive extra hop that disappears when you cover the index with INCLUDE.

A covering index eliminates the key lookup entirely. TheINCLUDE clause on CREATE INDEX stores extra columns only at the leaf level — not in the B+ tree nodes above — so they don't affect sort order or index depth. When every column in the SELECT list,WHERE clause, and JOIN predicates is present in the index key or its INCLUDE list, SQL Server never touches the clustered index. A plan that was performing 2,000 key lookups — one per matched row — becomes a single seek with no follow-up I/O.

Seeking: traverse root → intermediate → leaf, one page read per level, O(log n). Scanning: follow the NextPage pointer chain across leaf pages sequentially, ignoring the upper levels entirely after the starting leaf is found.

The optimizer chooses between seek and scan based on estimated selectivity — how many rows does the predicate match? High selectivity → seek. Low selectivity → scan. The crossover is roughly where seeking would require more than a third of leaf-level pages, though the actual threshold depends on row size and the cost model constants.

Index Structures ExplorerB+ tree for dbo.employee — clustered vs non-clustered, index seek and scan, key lookup, covered index with INCLUDE
open standalone →
The explorer uses dbo.employee (30 rows, ORDER=3 B+ tree).↓ south-park-orders.sql— includes dbo.employee, dbo.sales_order, and all demo tables. Run in any SQL Server development database to follow along with the real engine.
CREATE TABLE dbo.employee (
employee_id INT NOT NULL,
employee_name NVARCHAR(128) NOT NULL,
birth_year INT NOT NULL,
CONSTRAINT PK_Employee PRIMARY KEY CLUSTERED (employee_id)
);
SELECT employee_id, employee_name, birth_year
FROM dbo.employee
WHERE employee_id = 1— index seek on PK
slowfast
Select a row and press RUN to seek, or check no filter to scan.
CLUSTERED INDEX on employee_id — leaf pages store all columns (employee_id, employee_name, birth_year)
19713351Apu19722Bart19803Beth19804Bird19755Bob19626Butters19929117Chef19608Clyde19929Craig199210Eric199311Homer195612Jerry1958151713Jimmy199214Kenny199215Kyle199216Lisa198517Maggie198818Marge195625212319Morty200220Ned195621Patty195322Randy196023Rick194524Selma1953272925Stan199226Summer200027Tammy200128Token199229Tweek199230Wendy1992
■ internal — routing only■ CX leaf: id / name / year■ NC leaf: name / row-locator─ ─ NextPage pointerORDER=3 · 30 employees · 4 levels

Statistics and Cardinality Estimation

Selectivity estimates come from statistics. Statistics on a column are a histogram with up to 200 steps. Each step records five values: the upper boundary key (RANGE_HI_KEY), the rows with exactly that key value (EQ_ROWS), the rows in the range below the boundary (RANGE_ROWS), the distinct values in that range (DISTINCT_RANGE_ROWS), and the derived average rows per distinct value (AVG_RANGE_ROWS = RANGE_ROWS / DISTINCT_RANGE_ROWS). When the optimizer evaluates WHERE order_date = '2025-01-15', it looks up that date in the histogram and gets a row count estimate. That estimate drives every downstream decision: join order, join type, memory grant for hash operations, degree of parallelism.

When statistics are stale or misleading — the table was bulk-loaded, distribution shifted, or most rows cluster around values that fall between histogram steps — the estimate is wrong, and it propagates. A plan optimized for 100 rows that actually processes 10 million rows is one of SQL Server's most reliable methods for ruining your evening. Auto-update statistics helps; it triggers after roughly 20% of rows change (made adaptive for large tables in SQL Server 2016 — previously a 100-million-row table needed 20 million changes before anything updated). Don't rely on auto-update alone on tables with heavy writes or skewed distributions. ManualUPDATE STATISTICS WITH FULLSCAN after bulk loads is not optional maintenance. It is the price of having a cost-based optimizer.

Statistics / Histogram Explorer▶ DRILL

Data is evenly spread across the value range. The histogram approximates it well — estimates are close to actual.

# dbo.Orders — order_amount (0 – 9,999)
06312719025302.5k5k7.5k10k
# predicate
→ first bucket — estimate matches actual
# estimated rows
250
optimizer's guess from histogram
# actual rows
252
rows that would be returned
# estimation error
exact
close enough — plan is likely correct
# histogram steps — range / estimated rows
0–1.25k
1,251
1.25k–2.5k
1,248
2.5k–3.75k
1,251
3.75k–5k
1,247
5k–6.25k
1,253
6.25k–7.5k
1,249
7.5k–8.75k
1,250
8.75k–10k
1,252