An index is an auxiliary access structure that lets a database locate or order data without scanning every base row. “Add an index” is incomplete advice: B+ trees, hash tables, inverted indexes, spatial trees, block summaries, LSM-based layouts, and columnstores accelerate different operators and impose different write, memory, and maintenance costs.

Start from the operator in the measured query plan. Equality probes, ordered ranges, text terms, containment, nearest-neighbor search, and large analytical scans do not share one best structure. Rowstore and columnstore designs therefore remain parallel sections: one optimizes selective navigation and ordering, while the other compresses columns for broad analytical work. Statistics and physical maintenance follow only after the plan identifies which boundary is failing.

Structure Inventory

StructurePhysical ideaStrong query fitCost or boundary
B-tree / B+ treeBalanced, ordered pagesEquality, ranges, prefix order, ordered scansPage splits and write amplification across every maintained index
HashKey-to-bucket mappingEquality probes when the engine and workload support itNo useful key ordering or range scan; collisions and resizing still cost work
Inverted index / GINTerm or element to posting listFull text, arrays, document containmentLarger posting structures and expensive updates
GiST / SP-GiSTExtensible spatial or partitioned search treeGeometry, nearest neighbor, ranges, triesBehavior depends on the operator class; candidates may require recheck
BRINSummary per consecutive heap-page rangeVery large tables correlated with physical orderWeak pruning when row order is uncorrelated with the indexed value
LSM treeBuffer, flush, and compact sorted runsSustained writes with point/range reads through run indexes and filtersCompaction and read amplification; it is a storage organization, not a PostgreSQL index method
ColumnstoreCompressed values grouped by columnLarge scans and aggregates over a subset of columnsPoint updates and single-row OLTP access

The image is a vocabulary map, not a universal engine diagram. Bloom filters answer probable membership rather than locating a row; an LSM tree includes several cooperating structures; and products expose spatial and inverted behavior through engine-specific operator classes.

B+ Tree Boundary

Conventional SQL Server disk-based clustered and nonclustered rowstore indexes use B+ trees. Root and intermediate pages contain separator keys and child-page pointers. Leaf pages contain the table rows for a clustered index, or nonclustered keys plus row locators and included values for a nonclustered index. A heap has no clustered key order; its nonclustered indexes locate base rows by RID.

An index on (TenantId, CreatedAt) is ordered first by tenant and then by time within each tenant. It can seek an equality tenant and scan a time range without sorting the entire table. It is not an efficient general index for CreatedAt across all tenants because the leading key is missing.

PostgreSQL also defaults to B-tree for equality and ordering operators, but its heap and index implementation differs from SQL Server’s clustered-rowstore model. Do not transfer SQL Server leaf-layout or key-lookup claims to another engine without checking that engine’s plan and storage documentation.

Rowstore Indexes

Rowstore index design translates a recurring query shape into an ordered B+ tree. Key columns determine navigation and order; included columns exist at the nonclustered leaf to cover output; a filter limits which rows exist in the index. The target is the smallest index that supports the required predicates and ordering while paying for its write cost.

SELECT OrderNumber, Total, CreatedAt
FROM Orders
WHERE TenantId = @tenantId
  AND Status = @status
  AND CreatedAt >= @from
ORDER BY CreatedAt DESC;
 
CREATE INDEX IX_Orders_Tenant_Status_CreatedAt
    ON Orders (TenantId, Status, CreatedAt DESC)
    INCLUDE (OrderNumber, Total);

The equality predicates establish a tenant/status prefix, CreatedAt bounds the range and supplies output order, and the included values cover the projection without widening upper tree levels. The index cannot efficiently serve a general Status query because that query omits the leading tenant key.

SARGability and key order

SARGability means the optimizer can turn a predicate into a search argument. CreatedAt >= @from normally supplies a range; YEAR(CreatedAt) = @year usually does not unless the expression is rewritten as a date interval or exposed through an indexable computed column.

  • Put columns that bound a seek or preserve required order in the key.
  • Equality predicates usually precede the first range predicate.
  • “Most selective first” is not universal. Prefer prefixes reused by important queries, tenant or partition boundaries, and required ordering, then verify estimates.
  • A GROUP BY, ORDER BY, or join column belongs in the key only when its position supplies useful navigation or order.

Covering and filtered indexes

A nonclustered index covers a query when all required values can be returned from the index. Use INCLUDE for output or residual values whose order does not help a seek, join, grouping, or sort. Included values still widen leaf rows, consume cache, and add write work.

A filtered index stores only rows satisfying a stable predicate:

CREATE INDEX IX_Orders_Open_CreatedAt
    ON Orders (TenantId, CreatedAt DESC)
    INCLUDE (OrderNumber, Total)
    WHERE Status = 'Open';

This works when open orders are a small, frequently queried subset. The query predicate must imply the filter, and parameterization can prevent the optimizer from proving that implication. Verify actual and estimated rows, logical reads, lookups, sorts, write rate, size, and overlap with existing prefixes before keeping the index.

Columnstore Indexes

A SQL Server columnstore stores each column separately in compressed rowgroups and can execute eligible operators in batches. Broad queries that scan millions of rows but project a few columns read less data; point lookups, narrow seeks, and frequent single-row updates remain rowstore work.

CREATE CLUSTERED COLUMNSTORE INDEX CCI_SalesFact
ON dbo.SalesFact;
 
SELECT ProductCategory, SUM(Revenue)
FROM dbo.SalesFact
WHERE OrderDate >= '2026-01-01'
GROUP BY ProductCategory;

A clustered columnstore is the table’s primary storage. A nonclustered columnstore is an analytical copy over a rowstore table. Segment metadata can eliminate rowgroups whose value ranges cannot satisfy a predicate, and batch mode moves groups of values through eligible operators.

WorkloadBetter defaultReason
Warehouse fact table with large scansClustered columnstoreCompression, segment elimination, and batch aggregates
OLTP table with occasional analyticsRowstore plus selective nonclustered columnstorePreserves the point-write path while adding an analytical copy
Primary-key lookup and small updateRowstore B+ treeDirect seek and cheaper single-row maintenance

Small inserts first land in delta stores, background tuple movement compresses closed rowgroups, and updates become delete-plus-insert work. Use columnstore only when measured analytical savings pay those costs.

Index Maintenance

Maintenance should repair a measured problem, not follow a universal fragmentation threshold. Page density, logical fragmentation, statistics quality, query shape, storage, and the maintenance operation’s own cost all matter.

Logical fragmentation measures whether leaf pages follow key order. Page density measures how full those pages are. On SSD and cloud storage, sparse pages can matter more than out-of-order reads because a range scan must read more pages. A rebuild may appear to fix a plan only because it refreshed statistics; test that hypothesis first:

UPDATE STATISTICS dbo.Orders IX_Orders_Customer_CreatedAt
WITH FULLSCAN;
OperationWhat it changesCost or limit
REORGANIZEIncrementally compacts and orders leaf pagesDoes not refresh statistics
REBUILDRecreates the index and refreshes its statisticsLog, CPU, I/O, locking, and online-operation limits
UPDATE STATISTICSRefreshes cardinality distributionDoes not repair page density or ordering

Fill factor reserves free space during build or rebuild. Lower it only when measured page splits on non-sequential inserts justify permanently reading and caching more pages. The decision sequence is: capture the slow plan and reads, compare estimates with actuals, refresh relevant statistics, measure density and fragmentation for the used partition, then reorganize or rebuild only when the expected read benefit exceeds log, blocking, CPU, and I/O cost.

Choose from the Operator

Use the narrowest structure that supports the dominant operator and proves a net workload benefit:

  1. Capture the actual plan, row estimates, logical reads, elapsed time, and write rate.
  2. Identify the operator that dominates cost: scan, lookup, sort, text match, containment, or aggregation.
  3. Choose an engine-supported structure for that operator.
  4. Re-measure reads and writes. An index that speeds one query but doubles write cost or duplicates an existing prefix may be a net loss.

Low cardinality alone does not disqualify an index. A filtered index on a rare status, a covering ordered scan, or a bitmap-capable plan can still be useful. Conversely, a high-cardinality column is not automatically useful when queries do not filter, join, or order by it. Distribution, correlation, result size, and the surrounding plan determine whether the optimizer prefers the index.

Tradeoffs

  • Every secondary index consumes storage and makes inserts, deletes, and indexed-column updates maintain another structure.
  • A narrow index may require base-row lookups; a wide covering index reduces lookups but increases leaf size, cache pressure, and write cost.
  • Statistics and physical condition affect plan choice. A rebuild can appear to fix a query because it refreshed statistics; verify the cause before scheduling maintenance.
  • Specialized structures narrow the supported operator set. The advantage is worthwhile only when the workload repeatedly uses that operator.

Questions

References