Insights

The Index Ledger

What an index costs, what it earns, and how SQL Server 2022 finally keeps the books.
31,836rows in the table
62full scans
1,973,832rows read by scans
0uses of the index built for it

One table from a production audit, October 2026. The arithmetic is exact: 62 scans × 31,836 rows = 1,973,832 rows read, while the index sat unused.

Most indexing material explains what an index is: B-trees, clustered and nonclustered structures, leaf nodes. It explains it well. Production asks a different question. What does this index do for me, and what does it charge me for the privilege?

After reading one of those explainers, I opened a production database and checked whether its indexes were being used. One table of 31,836 rows had been read end to end 62 times. The index built specifically for that lookup had a usage counter of zero.

This article treats every index as an account with two columns. The cost column is charged on every insert, update and delete, and again in storage, buffer pool memory, transaction log volume and maintenance windows. The earnings column fills only when the optimizer chooses the index for a plan. An index problem is almost always a ledger where the second column is empty. The sections below explain how that happens, how to measure it in SQL Server, and which features in SQL Server 2022 change the balance.

01An index is a promise about order

An index is a sorted structure that lets the engine find rows without reading the table. SQL Server’s rowstore indexes are B+ trees built from 8 KB pages. Each page has a 96-byte header, which leaves 8,096 bytes for rows and the slot array. A root page points to intermediate pages, which point to leaf pages. Leaf pages are linked to their neighbours in both directions, so a range read can walk sideways once it has found its starting point.

What lives at the leaf level defines the index type. In a clustered index the leaf level is the table: rows are stored in key order, so there can be only one. In a nonclustered index the leaf holds the key plus a row locator that leads back to the full row. The locator’s form depends on the base table, and that detail matters more than most designs admit.

Figure 1Seeking key 4127 through a three-level B+ tree
A B+ tree with a root page, three intermediate pages and six leaf pages. The highlighted path from the root through the middle intermediate page to the fourth leaf page shows a seek for key 4127. In a clustered index the leaf pages hold the full rows: 3 page reads. 3 page reads ROOT · LEVEL 2 INTERMEDIATE · LEVEL 1 LEAF · LEVEL 0 page 1:9040 140008000 page 1:7712 11400 page 1:7713 40004100 page 1:7714 80009300 1 … full rows page 1:5501 1400 … full rows page 1:5502 4000 … full rows page 1:5509 4127 found full rows page 1:5512 8000 … full rows page 1:5520 9300 … full rows page 1:5521 The leaf level is the table: the whole row is already here. No extra hop. Range reads follow the sibling links left and right.
A B+ tree with a root page, three intermediate pages and six leaf pages. The highlighted path from the root through the middle intermediate page to the fourth leaf page shows a seek for key 4127. On a heap each leaf entry holds the key and an 8-byte RID, so the row costs one extra RID lookup into heap page 1:4410, slot 7. 3 reads + 1 RID lookup ROOT · LEVEL 2 INTERMEDIATE · LEVEL 1 LEAF · LEVEL 0 page 1:9040 140008000 page 1:7712 11400 page 1:7713 40004100 page 1:7714 80009300 1 … key + RID page 1:5501 1400 … key + RID page 1:5502 4000 … key + RID page 1:5509 4127 found key + RID page 1:5512 8000 … key + RID page 1:5520 9300 … key + RID page 1:5521 RID lookup Heap page 1:4410, slot 7 The 8-byte RID points straight at the row: one extra read per row
A B+ tree with a root page, three intermediate pages and six leaf pages. The highlighted path from the root through the middle intermediate page to the fourth leaf page shows a seek for key 4127. On a clustered table each leaf entry holds the key and the clustering key, so the row costs a key lookup: a full seek of a four-level clustered index for OrderId 88213. 3 reads + 4 per key lookup ROOT · LEVEL 2 INTERMEDIATE · LEVEL 1 LEAF · LEVEL 0 page 1:9040 140008000 page 1:7712 11400 page 1:7713 40004100 page 1:7714 80009300 1 … key + clustering key page 1:5501 1400 … key + clustering key page 1:5502 4000 … key + clustering key page 1:5509 4127 found key + clustering key page 1:5512 8000 … key + clustering key page 1:5520 9300 … key + clustering key page 1:5521 Key lookup Clustered index seek on OrderId = 88213 A full seek of a 4-level clustered index for every row returned
Reading the figure

Each box is one 8 KB page; the small grey label is its page ID in file:page form. Numbers inside a page are the first key of each child page. To find 4127 the engine reads the root, follows the pointer for keys from 4000, reads the intermediate page, follows the pointer for keys from 4100 and lands on the fourth leaf. That blue path costs three page reads at any table size this tree can hold.

Switch the buttons to compare leaf contents. A clustered leaf holds the row. A nonclustered leaf holds the key and a locator: an 8-byte RID (file, page, slot) on a heap, or the full clustering key on a clustered table. The orange hop is the price of every row the nonclustered index cannot answer by itself.

Order beats brute force because tree depth grows with the logarithm of the row count. A non-leaf page holding narrow keys points to hundreds of children, so each extra level multiplies capacity by that fan-out. Figure 2 plots that relationship with deliberately ordinary numbers.

Figure 2Pages read by a seek versus a full scan, as the table growsModel
1K 10K 100K 1M 10M 100M 1B 10B 1 10 100 1K 10K 100K 1M 10M 100M ROWS IN TABLE (LOG SCALE) 8 KB PAGES READ (LOG) Full scan: rows ÷ 100 pages Seek: one read per level, 2 to 4 reads 30M rows 4 reads vs 300,000 pages
Reading the figure

Both axes are logarithmic, so every gridline is ten times the previous one. The orange line is a full scan: pages read grow in step with rows. The blue staircase is a seek: one read per tree level. Each step marks the point where the tree needs one more level.

The model assumes 100 rows per leaf page and a fan-out of 500 per non-leaf page. With those numbers a 30-million-row table is four levels deep, so a seek costs 4 reads against 300,000 pages for a scan. The tree stays at four levels until about 12.5 billion rows. Your exact depth is in index_depth, returned by the query below.

T-SQL · actual depth and size of each index
SELECT  OBJECT_NAME(ips.object_id)  AS table_name,
        i.name                      AS index_name,
        ips.index_type_desc,
        ips.index_depth,            -- page reads for one seek
        ips.page_count,
        ips.avg_page_space_used_in_percent
FROM    sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'SAMPLED') AS ips
JOIN    sys.indexes AS i
        ON i.object_id = ips.object_id AND i.index_id = ips.index_id
WHERE   ips.page_count > 1000
ORDER BY ips.page_count DESC;

02The row locator has teeth

On a clustered table every nonclustered index row carries the entire clustering key as its locator. If the clustered index is not declared unique, SQL Server adds a hidden 4-byte uniquifier to duplicate keys as well. The clustering key is therefore not a local decision about one index. It is a width tax applied to every nonclustered index the table will ever have, at every leaf row.

Figure 3Leaf size of one nonclustered index on an INT column, 100 million rows, by clustering keyModel
0 GB 1 GB 2 GB 3 GB 4 GB 5 GB 6 GB 7 GB INT clustering key 4 bytes · 426 rows per page 1.79 GB BIGINT 8 bytes · 352 rows per page 2.17 GB UNIQUEIDENTIFIER 16 bytes · 261 rows per page 2.92 GB NVARCHAR(40) + DATETIME2 about 52 bytes · 120 rows per page 6.36 GB
Reading the figure

Each bar is the leaf level of the same nonclustered index: a 4-byte INT key plus the clustering key, with about 9 bytes of row overhead and a 2-byte slot entry. Only the clustering key changes between bars. Rows per page come from the 8,096 usable bytes, so wider rows mean fewer rows per page and more pages.

Moving from an INT clustering key to a natural composite key of a 20-character NVARCHAR and a DATETIME2 makes this one index 3.5 times larger. Multiply by the number of nonclustered indexes on the table, then add the buffer pool memory, backup size and log volume that follow page count.

The long-standing guidance for a clustering key still holds: narrow, static, unique and ever-increasing. Narrow keeps every nonclustered index small. Static avoids moving rows and rewriting every nonclustered entry when the key changes. Unique avoids the uniquifier. Ever-increasing avoids page splits in the middle of the tree. That last property concentrates inserts on the final page, and on very hot tables this produces PAGELATCH_EX contention. Since SQL Server 2019 the index option OPTIMIZE_FOR_SEQUENTIAL_KEY = ON mitigates it without changing the key.

03The optimizer decides, not you

You can create a perfect index and still get a scan. The optimizer is not being difficult. The query made the index unusable. A predicate that can use an index seek is called sargable (Search ARGument ABLE): the bare column is compared with a value whose type matches, in a form that maps to a contiguous key range.

Figure 4Will this predicate seek? A decision path
Flowchart with four yes/no questions. A yes at any of the first three leads to an index scan with a rewrite. A yes at the fourth means a scan is the correct plan. No at all four leads to an index seek. no no no no yes yes yes yes Is the column inside a function or expression? WHERE YEAR(OrderDate) = 2024 LIKE with a leading wildcard? WHERE Note LIKE '%invoice%' Does a type mismatch convert the column? CONVERT_IMPLICIT(int, OrderNumber) Does it match more rows than the tipping point? Status = 'Closed' -- 99% of rows Index ScanRewrite as a range onthe bare column Index ScanUse a prefix, full-text search,or an n-gram lookup table Index ScanDeclare the parameter withthe column’s exact type Scan is the right planCover the query or accept it;see section 7 Index Seek: the index earns its keep
Reading the figure

Read top to bottom. Each grey box is a question about one predicate on an indexed column, with a typical offender in monospace. Orange exits are self-inflicted scans, each with its fix. The fourth question is different: when a value matches a large share of the table, a scan is cheaper than a seek plus lookups, and the optimizer is right to choose it.

Only a predicate that answers “no” four times reaches the green seek. The first three questions are about how the query is written; the fourth is about the data.

PatternScansSeeksWhy
Function on columnWHERE YEAR(OrderDate) = 2024WHERE OrderDate >= '2024-01-01' AND OrderDate < '2025-01-01'The function must run on every row before comparison. A half-open range maps to one contiguous key range.
Arithmetic on columnWHERE Price * 1.2 > 100WHERE Price > 100 / 1.2Move the arithmetic to the constant side.
Leading wildcardLIKE '%invoice%'LIKE 'invoice%'A B+ tree is ordered by prefix. Without a known prefix there is no starting key.
Implicit conversionWHERE OrderNumber = 100245 (varchar column)WHERE OrderNumber = '100245'Data type precedence converts the varchar column to int. Look for CONVERT_IMPLICIT on the column side of the predicate in the actual plan.
ISNULL wrapperWHERE ISNULL(ClosedAt, '9999-12-31') > @dWHERE ClosedAt > @d OR ClosedAt IS NULLSame as any function. The OR form can seek on both branches.

Implicit conversion deserves its own picture, because it hides in application code rather than in the query text a DBA reviews. When two types meet in a comparison, SQL Server converts the one with lower data type precedence to the higher one. If the converted side is the column, every row is converted and the seek is lost.

Figure 5Data type precedence decides which side gets converted
LOWER PRECEDENCE HIGHER PRECEDENCE → varchar nvarchar uniqueid. bit tinyint smallint int bigint decimal float date datetime2 varchar column = 100245 column converts on every row → Scan int column = '100245' constant converts once → Seek varchar column = @p nvarchar column converts → Scan (SQL collation) or range seek (Windows collation) column value from the application
Reading the figure

The top track is a subset of SQL Server’s precedence order, lowest on the left. In each example the filled chip is the column and the outlined chip is the value from the application. The arrow always points from the lower type to the higher one, because the lower side is the one that gets converted.

Rows one and two use the same two types with opposite roles. When the column is varchar and the value is int, the column climbs and the seek is lost. When the column is int, the constant climbs once and the seek survives. Row three is the classic ORM case: a .NET string sent as nvarchar against a varchar column. Under a SQL collation it scans. Under a Windows collation the optimizer can often still seek through a computed range, but the plan will be costlier than with a matching type.

An index that is never chosen is not neutral. It is maintained on every write and earns nothing. The plan cache already records the conversions that cost you seeks; the query below finds them.

T-SQL · cached plans where a conversion blocked a seek
WITH XMLNAMESPACES (DEFAULT 'http://schemas.microsoft.com/sqlserver/2004/07/showplan')
SELECT TOP (50)
        st.text                          AS statement_text,
        qs.execution_count,
        qs.total_worker_time / 1000      AS total_cpu_ms,
        qp.query_plan
FROM    sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle)    AS st
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) AS qp
WHERE   qp.query_plan.exist('//PlanAffectingConvert[@ConvertIssue="Seek Plan"]') = 1
ORDER BY qs.total_worker_time DESC;

04Column order is the index

Ask ten developers what (TenantId, CreatedAt) indexes and most will say “TenantId and CreatedAt”. It indexes TenantId, and then CreatedAt within each tenant. The leaf level is one long list sorted by the first column, with the second column sorted only inside each run of equal first values. This is the leftmost-prefix property, and every rule about composite indexes follows from it. The explorer below lays out that leaf level so you can see which rows each query has to touch.

Figure 6Leaf level of IX (TenantId, CreatedAt): what each query readsInteractive
#1
#2
#3
#4
#5
#6
#7
#8
Tenant 1
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 2
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 3
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 4
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 5
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 6
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
row read but not returnedrow read and returnedrow not touched
Plan shape
Index Seek
Entries read
8 of 48
Rows returned
8
Wasted reads
0

Equality on the leading column lands on one contiguous run.

#1
#2
#3
#4
#5
#6
#7
#8
Tenant 1
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 2
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 3
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 4
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 5
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 6
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
row read but not returnedrow read and returnedrow not touched
Plan shape
Index Seek, equality + range
Entries read
6 of 48
Rows returned
6
Wasted reads
0

Equality fixes the run, then the range narrows it. The seek starts at March and reads only what it returns.

#1
#2
#3
#4
#5
#6
#7
#8
Tenant 1
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 2
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 3
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 4
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 5
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 6
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
row read but not returnedrow read and returnedrow not touched
Plan shape
Index Seek, backward · no Sort
Entries read
8 of 48
Rows returned
8
Wasted reads
0

Inside one tenant the dates are already ordered, so the engine reads the run backwards and skips the Sort.

#1
#2
#3
#4
#5
#6
#7
#8
Tenant 1
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 2
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 3
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 4
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 5
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 6
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
row read but not returnedrow read and returnedrow not touched
Plan shape
Index Seek, two ranges
Entries read
6 of 48
Rows returned
6
Wasted reads
0

Each IN value becomes its own seek. Two short runs, nothing wasted.

#1
#2
#3
#4
#5
#6
#7
#8
Tenant 1
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 2
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 3
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 4
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 5
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 6
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
row read but not returnedrow read and returnedrow not touched
Plan shape
Index Seek + Sort
Entries read
24 of 48
Rows returned
24
Wasted reads
0

The range on TenantId reads one block, but the dates restart for every tenant inside it. Order by CreatedAt needs a Sort operator.

#1
#2
#3
#4
#5
#6
#7
#8
Tenant 1
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 2
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 3
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 4
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 5
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
Tenant 6
Jan
Feb
Mar
Apr
May
Jun
Jul
Aug
row read but not returnedrow read and returnedrow not touched
Plan shape
Index Scan
Entries read
48 of 48
Rows returned
12
Wasted reads
36

Without TenantId there is no starting key. Matches are spread across every tenant’s run, so the whole leaf level is read.

Reading the figure

The grid is the leaf level in physical order: read it left to right, then down, like text. Each row of cells is one tenant and each cell is one entry, labelled with its CreatedAt month in 2026. Pick a query to see the entries the engine touches. Green cells are returned. Orange cells are read and thrown away.

Queries that fix TenantId touch one contiguous run, so they seek. The query on CreatedAt alone has matches in every tenant’s run and reads all 48 entries to return 12. The range on TenantId with ORDER BY CreatedAt reads a contiguous block, but within that block the dates restart for each tenant, so the plan still needs a Sort.

A composite index serves, in this order: equality on its leading columns, then either an ORDER BY or a range on the next column. It cannot give you both on the same column position. Once a range appears, the order of every later column is broken into separate runs. That is why “wider indexes serve more queries” is backwards: every extra key column makes the index larger and costlier to maintain, and serves only queries that also match the columns in front of it.

Columns that a query only needs to return belong in INCLUDE. Included columns live only at the leaf level. They do not widen the intermediate pages, do not count toward the 1,700-byte nonclustered key limit, and do not affect ordering. They turn a seek plus key lookups into a covering seek. Two cautions apply. Every included column is still written on every insert and on every update of that column. And the missing-index DMVs suggest equality columns followed by inequality columns, with no awareness of ORDER BY or of the indexes you already have, so treat their output as raw material for a design rather than as a design.

05Every index is a write tax

Each nonclustered index is a separate B+ tree that must be kept in step with the table. An INSERT modifies the base table and every nonclustered index; a filtered index is touched only when the row qualifies. A DELETE removes the entry from every index. An UPDATE touches the indexes whose key or included columns changed. All of it is logged, all of it takes locks and latches, and all of it competes for the same buffer pool.

Figure 7B+ trees modified by one single-row INSERT
0 2 4 6 8 10 1 0 2 1 3 2 4 3 5 4 6 5 7 6 8 7 9 8 10 9 NONCLUSTERED INDEXES ON THE TABLE B+ TREES MODIFIED 0 indexes: the table itself 9 indexes: one INSERT, ten trees, ten sets of log records
Reading the figure

The horizontal axis is the number of nonclustered indexes on a table; the bar height is the number of separate B+ trees one INSERT must modify. The blue first bar is the table itself. Every orange bar adds one more tree, with its own page reads, latches and log records.

The growth is linear, but its effects are not. Each tree adds pages to the working set, so beyond the point where hot pages stop fitting in memory, write latency rises faster than the bar heights suggest.

The tax has a second form: space that holds nothing. I once looked at a hot counter table with ten live rows occupying 6.8 MB. That measurement was taken on PostgreSQL, where every update leaves a dead row version behind until vacuum. SQL Server reaches a similar state by other routes: ghost records left by deletes, page splits that leave pages half empty, and row versions kept for snapshot isolation or accelerated database recovery.

Figure 8Ten live rows in 6.8 MB: one page of data among 870
1 page10 live rows fit in one 8 KB page.
869 pagesoccupied by dead versions, churn and index maintenance.
0.11%of the allocated space holds live data.
Reading the figure

Every square is one 8 KB page: 6.8 MB divided by 8 KB gives 870 squares. The single green square in the corner is all the space the ten live rows need. The orange squares are space the engine reads, caches, backs up and maintains, with nothing useful in it.

In SQL Server the matching evidence is a low avg_page_space_used_in_percent and a high ghost_record_count in sys.dm_db_index_physical_stats.

T-SQL · page density and ghost records for one table
SELECT  i.name AS index_name,
        ips.index_level,
        ips.page_count,
        ips.record_count,
        ips.ghost_record_count,
        ips.avg_page_space_used_in_percent
FROM    sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID(N'dbo.Counters'), NULL, NULL, 'DETAILED') AS ips
JOIN    sys.indexes AS i
        ON i.object_id = ips.object_id AND i.index_id = ips.index_id
WHERE   ips.index_level = 0;   -- leaf level only

06Keeping the ledger

sys.dm_db_index_usage_stats records both columns of the ledger for every index the engine has touched since startup. user_seeks, user_scans and user_lookups are the earnings: the number of times a query plan used the index. user_updates is the cost: the number of statements that had to maintain it. Note that both are counted per operation, not per row; one UPDATE that changes a million rows adds one to user_updates.

Figure 9The index ledger: reads earned against writes paidExample data
100 1K 10K 100K 1M 10M 100M 10 100 1K 10K 100K 1M 10M 100M 0 ZERO READS: THE DROP LIST reads = writes earns more than it costs ↖ costs more than it earns ↘ WRITES PAID · user_updates (LOG) READS EARNED (LOG) IX_Products_Sku IX_Orders_CustomerId IX_Orders_Status_Created IX_Payments_Ref IX_Report_MonthEnd UQ_Customers_Email IX_Orders_OrderYear IX_Orders_ShipCity IX_Audit_UserId
Reading the figure

Each dot is one index from an illustrative OLTP database. Horizontal position is user_updates, vertical position is seeks plus scans plus lookups, both on log scales. The dashed diagonal marks where reads equal writes; above it, an index is read more often than it is maintained. A log scale cannot show zero, so indexes with no reads at all sit in the separate band at the bottom.

That bottom band is the drop list. Indexes on the right end of it cost the most: IX_Audit_UserId has been maintained 41 million times and read never. Two dots need judgement rather than deletion. UQ_Customers_Email enforces uniqueness, so it does its job without being read. IX_Report_MonthEnd is read twelve times a month by a finance report, which is the exact case the next figure is about.

One caveat protects careers. These counters live in memory and reset when the instance restarts, when the database is taken offline or detached, and when AUTO_CLOSE closes it. Some older builds of SQL Server 2012 and 2014 also reset an index’s row when it was rebuilt. Always read sqlserver_start_time before you read the counters.

Figure 10How a restart turns a monthly index into an “unused” oneExample timeline
day 0 day 10 day 20 day 30 day 40 day 50 day 60 0 4 8 12 DBA checks here user_seeks = 0 every time restart (patching) counters reset to 0 month-end reports DAYS · IX_Report_MonthEnd, cumulative user_seeks
Reading the figure

The line is the cumulative user_seeks value for IX_Report_MonthEnd over sixty days. It stays flat for most of the month, then jumps when the finance team runs month-end reports on days 29 and 30. The dashed vertical line on day 34 is a patching restart, which wipes the counter back to zero.

The shaded band is a DBA checking the DMV between day 36 and day 58. Every reading in that window says zero. The index looks unused because the observation window was shorter than the business cycle. Measure across at least one full cycle, including month-end, quarter-end and any annual jobs, or persist snapshots of the DMV to a table so restarts stop erasing evidence.

T-SQL · the drop list, with its evidence window
-- 1. How long is the evidence?
SELECT sqlserver_start_time,
       DATEDIFF(DAY, sqlserver_start_time, SYSDATETIME()) AS days_of_evidence
FROM   sys.dm_os_sys_info;

-- 2. Nonclustered indexes ranked by cost with no earnings
SELECT  OBJECT_SCHEMA_NAME(i.object_id) AS schema_name,
        OBJECT_NAME(i.object_id)        AS table_name,
        i.name                          AS index_name,
        ISNULL(us.user_seeks, 0) + ISNULL(us.user_scans, 0)
          + ISNULL(us.user_lookups, 0)  AS reads_earned,
        ISNULL(us.user_updates, 0)      AS writes_paid,
        ps.used_pages * 8 / 1024.0      AS used_mb
FROM    sys.indexes AS i
JOIN    sys.objects AS o
        ON o.object_id = i.object_id AND o.type = 'U'
LEFT JOIN sys.dm_db_index_usage_stats AS us
        ON us.database_id = DB_ID()
       AND us.object_id = i.object_id AND us.index_id = i.index_id
CROSS APPLY (SELECT SUM(p.used_page_count) AS used_pages
             FROM sys.dm_db_partition_stats AS p
             WHERE p.object_id = i.object_id AND p.index_id = i.index_id) AS ps
WHERE   i.type_desc = 'NONCLUSTERED'
  AND   i.is_primary_key = 0
  AND   i.is_unique_constraint = 0
ORDER BY reads_earned ASC, writes_paid DESC;

Before you drop anything

  • Readable secondaries. The DMV is per instance. Reports running on an Availability Group secondary never appear on the primary, so an index can look idle on the primary and be essential on the replica.
  • Uniqueness. A unique index can be doing its job with zero reads. The query above excludes constraints, but not unique indexes created with CREATE UNIQUE INDEX.
  • Index hints and forced plans. Code using WITH (INDEX(IX_name)) fails when the index disappears, and a Query Store forced plan that references it stops being forceable. Search the code base and sys.query_store_plan where is_forced_plan = 1.
  • Reversibility. Disable before you drop. ALTER INDEX … DISABLE keeps the definition and stops maintenance; a rebuild restores it. Never disable a clustered index this way, because that makes the table unreadable.

07One plan for all parameters

SQL Server caches a plan per statement, not per parameter value. The first execution compiles the plan using the parameter values it was given, and every later execution reuses it. When data is evenly spread this is a great bargain. When data is skewed it is a trap called parameter sniffing.

Figure 11A skewed column: Orders by Status, 10 million rowsExample data
Closed 9,900,000 rows · 99% Pending 100,000 rows · 1%
Reading the figure

Both bars use the same linear scale. The Pending bar is a sliver because it is one hundredth of the Closed bar. The statistics histogram on Status records exactly this, so the optimizer knows that one value is rare and the other is almost everything.

For a rare value the best plan is a nonclustered seek followed by key lookups. For a common value it is a scan. The crossover between them is the tipping point, and Figure 12 shows why reusing the wrong plan is not a small mistake.

Figure 12The tipping point: seek plus lookups versus a clustered scanModel
1 10 100 1K 10K 100K 1M 10M 1 10 100 1K 10K 100K 1M 10M 100M ROWS MATCHED BY THE PARAMETER VALUE (LOG) LOGICAL READS (LOG) Clustered scan: 1,000,000 pages, whatever the value Seek + key lookups: 3 + 4 reads per row tipping point 250,000 rows SEEK WINS SCAN WINS Pending: 400,003 reads Closed on the Pending plan: 39.6M reads ≈ 40× the 1M reads of a scan
Reading the figure

The horizontal axis is the number of rows a value matches; the vertical axis is logical page reads, both on log scales. The flat orange line is a clustered index scan of the 10-million-row table, about 1,000,000 pages at 10 rows per page. The rising green line is a nonclustered seek plus one key lookup per row, at 4 reads per lookup into a four-level clustered index.

The lines cross at 250,000 rows, a quarter of the table’s page count, which matches the familiar rule of thumb that the tipping point sits between a quarter and a third of the pages. Pending sits left of the crossing, so its seek plan reads 400,000 pages, 2.5 times better than a scan. If that plan is cached and then reused for Closed, it reads 39.6 million pages instead of 1 million: about 40 times worse. The model ignores caching and read-ahead, but the shape is what matters.

For twenty years the fixes were manual, and each has a price:

FixMechanismPrice
OPTION (RECOMPILE)Compiles a fresh plan with the actual values on every execution.Compilation CPU on every call. Fine for a report, ruinous for a statement that runs thousands of times a second.
OPTIMIZE FOR (@p = value)Compiles for a value you choose.Correct for that value, wrong for the others, and the right value drifts as data changes.
OPTIMIZE FOR UNKNOWNUses the average density instead of the sniffed value.A plan that is mediocre for everyone.
Plan forcing (Query Store)Pins a known good plan.Someone must watch it. When data shifts, a forced plan can become the wrong plan.
Filtered indexWHERE Status = 'Pending' on the index itself, so it holds 1% of rows.Tiny and cheap to maintain, but a parameterized predicate cannot match it unless the plan is compiled for the literal value.

08SQL Server 2022 changes the economics

SQL Server 2022 is the release where the engine starts keeping its own books. The common thread is persistence: the engine records what happened at runtime and uses it on the next compilation, instead of relying on a DBA to notice.

Query Store is the foundation, and it is on by default only for new databases

Query Store persists query text, plans and runtime statistics across restarts, which makes plan regressions visible and provable. In SQL Server 2022 it is enabled in READ_WRITE mode for newly created databases. Databases that were restored or upgraded in place keep the settings they arrived with, so on an upgraded server it may still be off. Check before relying on anything below.

T-SQL · is the foundation in place?
SELECT name, compatibility_level, is_query_store_on
FROM   sys.databases
WHERE  database_id > 4;

-- run inside each database
SELECT actual_state_desc, desired_state_desc, readonly_reason,
       current_storage_size_mb, max_storage_size_mb
FROM   sys.database_query_store_options;

ALTER DATABASE [Sales] SET QUERY_STORE = ON
      (OPERATION_MODE = READ_WRITE, QUERY_CAPTURE_MODE = AUTO);

Parameter Sensitive Plan optimization

PSP optimization attacks parameter sniffing at the root. At compatibility level 160, when the statistics histogram shows skew on a parameterized equality predicate, the optimizer compiles a dispatcher plan. At runtime the dispatcher places the parameter value in a low, medium or high cardinality bucket and routes execution to a query variant with its own plan. Up to three predicates per query are considered, a deliberate cap so the plan cache cannot explode.

Figure 13PSP optimization: one statement, a dispatcher, three plans
A procedure call feeds a dispatcher plan, which routes to one of three query variants by the cardinality of the parameter: low, medium or high. EXEC dbo.GetOrders @CustomerId = ? Dispatcher plan reads the histogram for @CustomerId and picks a bucket at execution time compat level 160 low medium high Variant 1 · low cardinality Index Seek + Key Lookup, nested loops a customer with 12 orders Variant 2 · medium cardinality Index Seek + Hash Match a customer with 40,000 orders Variant 3 · high cardinality Clustered Index Scan, parallel the marketplace account, 3.1M orders
Reading the figure

Read left to right. The call arrives with a parameter value. The blue dispatcher does not run the query itself; it looks up how many rows that value is expected to match and sends execution down one of three paths. Each variant on the right is a separately compiled and cached plan, shaped for its bucket. The example customers show the kind of value that lands in each bucket.

Compare this with Figure 12. Without PSP, whichever value arrived first decided the plan for everyone. With PSP, the Pending-style value gets the seek and the Closed-style value gets the scan, from the same statement text. Variants appear in Query Store and can be listed from sys.query_store_query_variant.

Feedback that survives restarts

Three feedback features learn from repeated executions and store what they learned in Query Store. Cardinality estimation feedback spots queries whose estimates are consistently wrong because of a model assumption, such as how correlated two predicates are, and adjusts that assumption for the next compilation. Degree of parallelism feedback lowers the DOP of queries that waste threads. Memory grant feedback in percentile mode sizes grants from recent history rather than the last run, which stops queries with oscillating needs from alternating between spills and wasted memory.

Figure 14The feedback loop, with Query Store as its memory
A cycle of four steps around Query Store: execute, observe, persist, adjust. Query Store READ_WRITE Execute query runs with its plan Observe actual rows, DOP, grant Persist feedback saved as hints Adjust next compile corrects
Reading the figure

Follow the arrows clockwise from the top. A query runs; the engine compares what it estimated with what happened; the correction is written to Query Store; the next compilation of the same query uses it. Query Store sits in the middle because every step after Execute reads or writes it.

The practical consequence: if Query Store is off or has flipped to READ_ONLY because it ran out of space, the loop is cut and these features either do nothing or forget what they learned at the next restart.

Figure 15What SQL Server 2022 needs before each feature works
FeatureWhat it fixesCompatibility levelQuery StoreDefault state
Query StorePlan history and runtime stats that survive restartsAnyn/aOn for new databases only
PSP optimizationParameter sniffing on skewed equality predicates160not requiredOn at 160
CE feedbackRepeated misestimates caused by model assumptions160READ_WRITE requiredOn at 160
DOP feedbackRepeating queries that use more parallelism than they benefit from160READ_WRITE requiredOff: set DOP_FEEDBACK = ON
Memory grant feedback, percentile and persistenceSpills and wasted grants when memory needs oscillate140 (row mode 150)required for persistenceOn
Optimized plan forcingCompilation cost of forced plansAnyrequiredOn
Query Store hintsChanging a plan without touching application codeAnyrequiredAvailable
Approximate percentilesFast P95 and P99 on very large sets—not requiredAvailable
Reading the figure

Each row is a feature; read across to see what it needs. The two gates that matter most are the compatibility level column and the Query Store column. A red pill marks a hard dependency or a feature that stays off until you act. Amber marks the Query Store default, which depends on how the database arrived on the server.

The pattern is plain once laid out: most of the intelligence needs compatibility level 160, and most of the learning needs Query Store in READ_WRITE. Confirm the details against the documentation for your exact build before a rollout.

T-SQL · unlock, then shape without code changes
ALTER DATABASE [Sales] SET COMPATIBILITY_LEVEL = 160;
ALTER DATABASE SCOPED CONFIGURATION SET DOP_FEEDBACK = ON;

-- PSP variants created so far
SELECT query_variant_query_id, parent_query_id, dispatcher_plan_id
FROM   sys.query_store_query_variant;

-- Query Store hint: recompile one query without touching the application
EXEC sys.sp_query_store_set_hints
     @query_id    = 4217,
     @query_hints = N'OPTION (RECOMPILE)';

-- Approximate P99 instead of sorting every row
SELECT APPROX_PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY duration_ms) AS p99_ms
FROM   dbo.RequestLog;

09The workflow that actually works

Index tuning goes wrong when it starts from the slowest query someone remembers. Start from where the server spends its time. In Query Store, rank by total CPU and total duration, not by average.

Figure 16The same five queries ranked two waysExample data
SORTED BY AVERAGE DURATION ExportLedger 45,000 ms MonthlyRevenue 10,000 ms SearchProducts 180 ms UpdateInventory 12 ms GetCartItems 5 ms
SORTED BY TOTAL DURATION, 7 DAYS SearchProducts 21,600 s UpdateInventory 9,600 s GetCartItems 5,000 s ExportLedger 1,350 s MonthlyRevenue 40 s
Reading the figure

Both panels show the same five queries over one week; only the sort key differs. On the left, bars are average duration per execution. On the right, bars are average multiplied by execution count. Two queries are coloured so you can follow them across panels: blue GetCartItems runs a million times at 5 ms, and orange MonthlyRevenue runs four times at 10 seconds.

Ranked by average, the monthly report looks like the problem and the cart query is invisible at the bottom. Ranked by total, the cart query consumes 125 times more server time than the report. Saving 1 ms on GetCartItems returns 1,000 seconds a week; making the report instant returns 40.

T-SQL · top queries by total CPU, last 7 days
SELECT TOP (20)
        q.query_id,
        SUM(rs.count_executions)                              AS executions,
        SUM(rs.avg_cpu_time  * rs.count_executions) / 1000.0  AS total_cpu_ms,
        SUM(rs.avg_duration * rs.count_executions) / 1000.0  AS total_duration_ms,
        MAX(qt.query_sql_text)                                AS query_text
FROM    sys.query_store_runtime_stats          AS rs
JOIN    sys.query_store_runtime_stats_interval AS rsi
        ON rsi.runtime_stats_interval_id = rs.runtime_stats_interval_id
JOIN    sys.query_store_plan       AS p  ON p.plan_id = rs.plan_id
JOIN    sys.query_store_query      AS q  ON q.query_id = p.query_id
JOIN    sys.query_store_query_text AS qt ON qt.query_text_id = q.query_text_id
WHERE   rsi.start_time >= DATEADD(DAY, -7, SYSUTCDATETIME())
GROUP BY q.query_id
ORDER BY total_cpu_ms DESC;

For the top offenders, open the actual execution plan and look for three signatures: a scan where you expected a seek, CONVERT_IMPLICIT on a column, and a fat arrow where the estimate says 1 row and the actual count says 100,000. Then work in a loop.

Figure 17The index tuning loop
1 Check uptime sqlserver_start_time 2 Find subscriptions zero reads, high user_updates 3 Rank by totals Query Store: total CPU, duration 4 Read the actual plan scans, CONVERT_IMPLICIT, fat arrows 5 Change one index one variable per cycle 6 Re-measure same queries, same window 7 Drop what earns nothing every month One change, one measurement, repeat
Reading the figure

The numbers are the order of work, starting at the top and running clockwise. Steps 1 and 2 establish evidence: how long the counters have been collecting and which indexes cost without earning. Steps 3 and 4 find where time goes and why. Steps 5 and 6 are the experiment: change one index, then measure the same queries in the same Query Store window.

It is drawn as a circle because step 7 leads back to step 1. One change per cycle is the discipline that makes the measurement mean something; two simultaneous changes produce a result nobody can attribute.

10Governance for architects

Unused indexes are not a tuning failure. They are a governance failure: an index is easy to add, nobody owns its removal, and its cost is spread so thinly across every write that no single incident points at it. A few standing rules change that.

  • Every index has a reason on record. The query or Query Store query_id it was created for, and an owner, kept in an extended property or the change ticket.
  • Hot OLTP tables have an index budget. Set a ceiling per table, for example five nonclustered indexes, and require a measured justification to exceed it.
  • The ledger is reviewed monthly. DMV snapshots are persisted to a table so restarts do not erase evidence, and the review spans month-end.
  • Removal is staged. Disable, observe through a full business cycle, then drop.
  • Compatibility level changes are projects. Baseline, switch, compare, force where needed, fix the cause, then unforce.
  • Query Store health is monitored. Alert when actual_state_desc differs from desired_state_desc, because a Query Store that silently flipped to read-only disables the 2022 feedback loop.

11The lesson

The explainer taught me what an index is. Production taught me what an index costs.

An index is a contract with the optimizer. It says this predicate is selective, this column is sargable, and this key order matches the query. Break any clause of that contract and you still pay the maintenance. You simply get nothing back.

So the question worth asking about your own database is not which index is biggest. It is which index charges you writes every second and has never earned a single read.

That one is not an asset. It is a subscription.

Download the full article

The Index Ledger as a PDF: 31 pages with all 17 figures and every T-SQL query, ready to read offline or share with your team. 1.4 MB.

Download PDF

Sources and notes

  • Microsoft Learn: Clustered and nonclustered indexes described; Intelligent query processing; Parameter Sensitive Plan optimization; sys.dm_db_index_usage_stats; Query Store hints.
  • Microsoft SQL Server Blog, “Query Store is enabled by default in SQL Server 2022”, 18 August 2022.
  • Measured example: a production database audited in October 2026, 31,836 rows, 62 sequential scans reading 1,973,832 rows, and an index with zero recorded scans. That engine was PostgreSQL; the principle is identical, and sys.dm_db_index_usage_stats tells the same story in SQL Server. The 6.8 MB counter table is from the same audit.
  • Figures marked “Model” use stated, simplified assumptions to show shape and scale. Figures marked “Example data” use illustrative values, not measurements from a specific system.

#SQLServer #SQLServer2022 #DatabasePerformance #Indexing #QueryStore #DataEngineering