Verified by Garnet Grid

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 TypeMeaningFix
PAGEIOLATCH_*I/O — data not in buffer poolAdd RAM, fix indexing
CXPACKETParallelism waitsReview MAXDOP, CTFP
LCK_M_*Lock contentionReview isolation levels, queries
SOS_SCHEDULER_YIELDCPU pressureOptimize queries, add CPU
WRITELOGTransaction log I/OFaster log disk, fewer transactions
ASYNC_NETWORK_IOClient consuming results slowlyFix 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

OperatorWarning SignAction
Table ScanFull table is readAdd an index on filter columns
Key LookupIndex found row but needs more columnsAdd INCLUDE columns to index
Nested Loop on large tableO(n²) performanceConsider HASH JOIN or index
Sort with spill to tempdbInsufficient memory grantOptimize query or increase memory
Parallelism (unexpected)MAXDOP not configuredSet MAXDOP appropriately
Thick arrowsLarge data movementFilter 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. :::