How to Tune SQL Server Performance: Indexes, Query Plans, and Wait Stats
Diagnose and fix SQL Server performance issues. Covers index analysis, execution plan reading, wait statistics, parameter sniffing, and tempdb optimization.
SQL Server performance problems are almost never the engine’s fault. They’re caused by missing indexes, bad query patterns, and misconfigured settings. This guide gives you the diagnostic toolkit.
Step 1: Identify the Problem (Wait Stats First)
Wait statistics tell you what SQL Server is spending time doing. Always start here.
-- Top 10 cumulative wait types
SELECT TOP 10
wait_type,
wait_time_ms / 1000.0 AS wait_time_sec,
signal_wait_time_ms / 1000.0 AS signal_wait_sec,
waiting_tasks_count,
wait_time_ms * 100.0 / SUM(wait_time_ms) OVER() AS pct
FROM sys.dm_os_wait_stats
WHERE wait_type NOT IN (
'CLR_SEMAPHORE', 'LAZYWRITER_SLEEP', 'RESOURCE_QUEUE',
'SLEEP_TASK', 'SLEEP_SYSTEMTASK', 'SQLTRACE_BUFFER_FLUSH',
'WAITFOR', 'LOGMGR_QUEUE', 'CHECKPOINT_QUEUE',
'REQUEST_FOR_DEADLOCK_SEARCH', 'XE_TIMER_EVENT',
'BROKER_TO_FLUSH', 'BROKER_TASK_STOP', 'CLR_MANUAL_EVENT',
'DISPATCHER_QUEUE_SEMAPHORE', 'FT_IFTS_SCHEDULER_IDLE_WAIT',
'XE_DISPATCHER_WAIT', 'DIRTY_PAGE_POLL', 'HADR_FILESTREAM_IOMGR_IOCOMPLETION'
)
ORDER BY wait_time_ms DESC;
Wait Type Interpretation
| Wait Type | Meaning | Fix |
|---|---|---|
PAGEIOLATCH_* | I/O — data not in buffer pool | Add RAM, fix indexing |
CXPACKET | Parallelism waits | Review MAXDOP, CTFP |
LCK_M_* | Lock contention | Review isolation levels, queries |
SOS_SCHEDULER_YIELD | CPU pressure | Optimize queries, add CPU |
WRITELOG | Transaction log I/O | Faster log disk, fewer transactions |
ASYNC_NETWORK_IO | Client consuming results slowly | Fix application, network |
Step 2: Find Missing Indexes
-- Most impactful missing indexes
SELECT TOP 20
ROUND(s.avg_total_user_cost * s.avg_user_impact * (s.user_seeks + s.user_scans), 0) AS [Impact],
d.statement AS [Table],
d.equality_columns AS [Equality],
d.inequality_columns AS [Inequality],
d.included_columns AS [Include],
s.user_seeks,
s.user_scans,
s.avg_user_impact AS [Avg Impact %]
FROM sys.dm_db_missing_index_details d
JOIN sys.dm_db_missing_index_groups g ON d.index_handle = g.index_handle
JOIN sys.dm_db_missing_index_group_stats s ON g.index_group_handle = s.group_handle
ORDER BY [Impact] DESC;
Index Creation Template
-- Create a covering index based on missing index analysis
CREATE NONCLUSTERED INDEX IX_Orders_CustomerDate
ON dbo.Orders (CustomerID, OrderDate)
INCLUDE (TotalAmount, Status)
WITH (ONLINE = ON, SORT_IN_TEMPDB = ON);
:::caution[Don’t Over-Index]
Each index speeds up reads but slows down writes. A table with 15+ indexes has a write performance problem. Monitor sys.dm_db_index_usage_stats to find unused indexes and drop them.
:::
Step 3: Read Execution Plans
3.1 Capture the Plan
-- Enable actual execution plan for a query
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
-- Your problem query here
SELECT c.CustomerName, SUM(o.TotalAmount)
FROM Customers c
JOIN Orders o ON c.CustomerID = o.CustomerID
WHERE o.OrderDate >= '2026-01-01'
GROUP BY c.CustomerName
ORDER BY SUM(o.TotalAmount) DESC;
3.2 Red Flags in Execution Plans
| Operator | Warning Sign | Action |
|---|---|---|
| Table Scan | Full table is read | Add an index on filter columns |
| Key Lookup | Index found row but needs more columns | Add INCLUDE columns to index |
| Nested Loop on large table | O(n²) performance | Consider HASH JOIN or index |
| Sort with spill to tempdb | Insufficient memory grant | Optimize query or increase memory |
| Parallelism (unexpected) | MAXDOP not configured | Set MAXDOP appropriately |
| Thick arrows | Large data movement | Filter earlier in query |
Step 4: Fix Parameter Sniffing
Parameter sniffing causes a plan optimized for one parameter value to perform terribly for another.
-- Option 1: OPTIMIZE FOR UNKNOWN
SELECT * FROM Orders
WHERE CustomerID = @CustomerID
OPTION (OPTIMIZE FOR (@CustomerID UNKNOWN));
-- Option 2: RECOMPILE (for infrequent queries)
SELECT * FROM Orders
WHERE CustomerID = @CustomerID
OPTION (RECOMPILE);
-- Option 3: Plan guides (surgical fix)
EXEC sp_create_plan_guide
@name = N'OrdersByCustomer_Guide',
@stmt = N'SELECT * FROM Orders WHERE CustomerID = @CustomerID',
@type = N'SQL',
@hints = N'OPTION (OPTIMIZE FOR (@CustomerID UNKNOWN))';
Step 5: Configure Server Settings
-- MAXDOP — prevent excessive parallelism
-- Rule: min(8, number of cores per NUMA node)
EXEC sp_configure 'max degree of parallelism', 4;
-- Cost Threshold for Parallelism — raise from default 5
EXEC sp_configure 'cost threshold for parallelism', 50;
-- Max Server Memory — leave 10-20% for OS
-- Example: 64 GB server → set to 52 GB
EXEC sp_configure 'max server memory (MB)', 53248;
RECONFIGURE;
Step 6: Optimize TempDB
-- Check for tempdb contention
SELECT
wait_type,
wait_time_ms,
waiting_tasks_count
FROM sys.dm_os_wait_stats
WHERE wait_type LIKE 'PAGELATCH%'
ORDER BY wait_time_ms DESC;
-- Fix: Multiple tempdb data files (1 per CPU core, up to 8)
ALTER DATABASE tempdb
ADD FILE (
NAME = tempdb2,
FILENAME = 'D:\TempDB\tempdb2.ndf',
SIZE = 1024MB,
FILEGROWTH = 256MB
);
Performance Tuning Checklist
- Analyze wait statistics (top 5 wait types)
- Review and create missing indexes (top 10)
- Drop unused indexes (0 seeks/scans in 30 days)
- Review top 10 most expensive queries (Plan Cache)
- Fix parameter sniffing issues
- Configure MAXDOP and Cost Threshold for Parallelism
- Set Max Server Memory appropriately
- Optimize tempdb (multiple files, correct sizing)
- Enable Query Store for baseline tracking
- Schedule index maintenance (rebuild > 30% fragmentation)
:::note[Source] This guide is derived from operational intelligence at Garnet Grid Consulting. For a SQL performance audit, visit garnetgrid.com. :::