Index Strategies
Comprehensive guide to SQL Server index design and optimization.
Quick Reference
Index Types
| Type |
Description |
Best For |
| Clustered |
Table data order |
Primary access path, range scans |
| Nonclustered |
Separate structure |
Specific query patterns |
| Columnstore |
Column-based storage |
Analytics, aggregations |
| Filtered |
Partial index |
Well-known subsets |
| Covering |
All columns needed |
Avoiding key lookups |
Clustered Index Guidelines
Ideal Clustered Key:
- Narrow (small data type)
- Unique or mostly unique
- Ever-increasing (identity, sequential GUID)
- Static (rarely updated)
-- Good: Identity column
CREATE CLUSTERED INDEX CIX_Orders ON Orders(OrderID);
-- Good: Sequential GUID
CREATE TABLE Orders (
OrderID UNIQUEIDENTIFIER DEFAULT NEWSEQUENTIALID() PRIMARY KEY CLUSTERED
);
-- Avoid: Wide composite keys, frequently updated columns, GUIDs (NEWID)
Nonclustered Index Design
-- Basic index
CREATE NONCLUSTERED INDEX IX_Orders_CustomerID
ON Orders(CustomerID);
-- Covering index (avoids key lookup)
CREATE NONCLUSTERED INDEX IX_Orders_CustomerID_Cover
ON Orders(CustomerID)
INCLUDE (OrderDate, TotalAmount, Status);
-- Filtered index (partial)
CREATE NONCLUSTERED INDEX IX_Orders_Active
ON Orders(CustomerID, OrderDate)
WHERE Status = 'Active';
-- Descending order
CREATE NONCLUSTERED INDEX IX_Orders_DateDesc
ON Orders(OrderDate DESC, OrderID DESC);
Index Selection Guide
By Query Pattern
| Pattern |
Recommended Index |
WHERE Col = value |
Nonclustered on Col |
WHERE Col = v1 AND Col2 = v2 |
Nonclustered on (Col, Col2) |
WHERE Col = v ORDER BY Col2 |
Nonclustered on (Col, Col2) |
WHERE Col BETWEEN x AND y |
Col as leftmost key |
SELECT * WHERE Col = v |
Clustered or covering NC |
| Large aggregations |
Columnstore |
| Specific subset queries |
Filtered index |
Column Order in Composite Keys
-- Order matters! Left-to-right matching
CREATE INDEX IX_Example ON Table(A, B, C);
-- These queries CAN use the index:
WHERE A = 1
WHERE A = 1 AND B = 2
WHERE A = 1 AND B = 2 AND C = 3
WHERE A = 1 AND B > 5 ORDER BY B
-- These queries CANNOT use index seek:
WHERE B = 2 -- A not specified
WHERE B = 2 AND C = 3 -- A not specified
WHERE A = 1 AND C = 3 -- B skipped (partial match only)
Columnstore Indexes
Clustered Columnstore
-- Best for data warehousing
CREATE CLUSTERED COLUMNSTORE INDEX CCI_FactSales
ON FactSales;
-- Ordered columnstore (SQL 2022+)
CREATE CLUSTERED COLUMNSTORE INDEX CCI_FactSales
ON FactSales
ORDER (DateKey, ProductKey);
Nonclustered Columnstore
-- Hybrid OLTP/OLAP
CREATE NONCLUSTERED COLUMNSTORE INDEX NCCI_Orders_Analysis
ON Orders(OrderDate, ProductID, Quantity, Amount)
WHERE Status = 'Completed';
Columnstore Best Practices
- Load batches >= 102,400 rows - Creates compressed segments
- Order data by filtered columns - Better segment elimination
- Use REORGANIZE, not REBUILD - More efficient maintenance
- Avoid frequent small updates - Causes deltastore fragmentation
- Partition by date - Enables partition elimination
-- Maintenance
ALTER INDEX CCI_FactSales ON FactSales REORGANIZE;
-- Check fragmentation
SELECT
object_name(object_id) AS TableName,
index_id,
avg_fragmentation_in_percent,
fragment_count
FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'LIMITED');
Filtered Indexes
-- Index active orders only
CREATE NONCLUSTERED INDEX IX_Orders_Active
ON Orders(CustomerID, OrderDate)
WHERE Status = 'Active';
-- Index non-NULL values
CREATE UNIQUE INDEX IX_Users_Email
ON Users(Email)
WHERE Email IS NOT NULL;
-- Constraints:
-- - Cannot use variable in filter
-- - Query WHERE must match or be subset of filter WHERE
-- - May cause parameter sniffing issues
Covering Indexes
-- Eliminate key lookups
-- Original: Index on CustomerID, query selects OrderDate, Amount
-- Execution plan shows Key Lookup
-- Solution: Covering index
CREATE INDEX IX_Orders_CustomerID_Cover
ON Orders(CustomerID)
INCLUDE (OrderDate, Amount, Status);
-- INCLUDE columns:
-- - Not in key (not sorted)
-- - Stored at leaf level only
-- - Don't contribute to 900-byte key limit
-- - Perfect for frequently selected columns
Index Maintenance
Fragmentation Guidelines
| Fragmentation % |
Action |
| < 5% |
None needed |
| 5-30% |
REORGANIZE |
| > 30% |
REBUILD |
-- Reorganize (online, minimal locking)
ALTER INDEX IX_Orders_CustomerID ON Orders REORGANIZE;
-- Rebuild (offline by default, more thorough)
ALTER INDEX IX_Orders_CustomerID ON Orders REBUILD;
-- Online rebuild (Enterprise Edition)
ALTER INDEX IX_Orders_CustomerID ON Orders
REBUILD WITH (ONLINE = ON);
-- Resumable rebuild (SQL 2017+)
ALTER INDEX IX_Orders_CustomerID ON Orders
REBUILD WITH (ONLINE = ON, RESUMABLE = ON, MAX_DURATION = 60);
-- Resume interrupted rebuild
ALTER INDEX IX_Orders_CustomerID ON Orders RESUME;
Statistics Update
-- Update after index changes
UPDATE STATISTICS Orders;
-- Full scan for accurate stats
UPDATE STATISTICS Orders WITH FULLSCAN;
-- Check last update
SELECT
OBJECT_NAME(object_id) AS TableName,
name AS StatsName,
STATS_DATE(object_id, stats_id) AS LastUpdated
FROM sys.stats
WHERE object_id = OBJECT_ID('Orders');
Performance Monitoring
Index Usage Stats
SELECT
OBJECT_NAME(i.object_id) AS TableName,
i.name AS IndexName,
ius.user_seeks,
ius.user_scans,
ius.user_lookups,
ius.user_updates
FROM sys.indexes i
LEFT JOIN sys.dm_db_index_usage_stats ius
ON i.object_id = ius.object_id
AND i.index_id = ius.index_id
WHERE OBJECTPROPERTY(i.object_id, 'IsUserTable') = 1
ORDER BY ius.user_seeks + ius.user_scans DESC;
Missing Index Recommendations
SELECT
migs.avg_user_impact AS ImpactPercent,
mid.statement AS TableName,
mid.equality_columns,
mid.inequality_columns,
mid.included_columns
FROM sys.dm_db_missing_index_groups mig
JOIN sys.dm_db_missing_index_group_stats migs
ON mig.index_group_handle = migs.group_handle
JOIN sys.dm_db_missing_index_details mid
ON mig.index_handle = mid.index_handle
ORDER BY migs.avg_user_impact DESC;