Skip to content

Bits of .NET

Daily micro-tips for C#, SQL, performance, and scalable backend engineering.

  • Asp.Net Core
  • C#
  • SQL
  • JavaScript
  • CSS
  • About
  • ErcanOPAK.com
  • No Access
  • Privacy Policy
SQL

SQL Server Execution Plan Analysis: Find and Fix Slow Queries Like a Pro

- 19.08.26 | 19.08.26 - ErcanOPAK

Your application is slow, and you suspect the database. Execution plans reveal exactly what SQL Server is doing and where the bottlenecks are.

1. Capture Actual Execution Plan:

-- ✅ Capture actual execution plan
SET STATISTICS IO ON;
SET STATISTICS TIME ON;

-- Run your query
SELECT 
    o.OrderID,
    o.OrderDate,
    c.CustomerName,
    p.ProductName,
    od.Quantity,
    od.UnitPrice
FROM Orders o
JOIN Customers c ON o.CustomerID = c.CustomerID
JOIN OrderDetails od ON o.OrderID = od.OrderID
JOIN Products p ON od.ProductID = p.ProductID
WHERE o.OrderDate >= '2024-01-01'
  AND o.OrderDate < '2024-02-01'
  AND o.Status = 'Shipped'
  AND c.Country = 'USA'
  AND p.Category = 'Electronics'
ORDER BY o.OrderDate DESC;

-- Look at output:
-- (1) Actual execution plan (in SSMS, Execution Plan tab)
-- (2) STATISTICS IO: Logical reads, physical reads
-- (3) STATISTICS TIME: CPU time, elapsed time

2. Understanding the Execution Plan:

Execution Plan Operators (Most Common):

1. Table Scan / Clustered Index Scan (??? Worst)
   - Reading entire table/index
   - Cost: High (proportional to table size)
   - Fix: Add WHERE clause or index

2. Index Seek (✅ Best)
   - Using index to find specific rows
   - Cost: Low (proportional to result set)
   - Good! Keep it.

3. Key Lookup / RID Lookup (⚠️ Moderate)
   - Getting extra columns after index seek
   - Cost: Low to Medium
   - Fix: Use covered index (include columns)

4. Nested Loops (⚠️ Moderate)
   - Loop join for small tables
   - Cost: Low (small tables)

5. Hash Match (⚠️ Moderate)
   - Hash join for large tables
   - Cost: Medium

6. Merge Join (✅ Best)
   - Merge sorted inputs
   - Cost: Medium but efficient

7. Sort (⚠️ Moderate)
   - Sorting operation
   - Cost: Medium to High
   - Fix: Use index to avoid sort

8. Filter (⚠️ Moderate)
   - Predicate filtering
   - Cost: Low to Medium

9. Compute Scalar (✅ Low)
   - Computed values
   - Cost: Low

3. Identify Missing Indexes:

-- Missing Index Recommendation
SELECT 
    migs.avg_total_user_cost * migs.avg_user_impact * (migs.user_seeks + migs.user_scans) AS Impact,
    migs.avg_total_user_cost,
    migs.avg_user_impact,
    migs.user_seeks,
    migs.user_scans,
    'CREATE INDEX idx_' + 
        REPLACE(REPLACE(REPLACE(mid.statement, ']', ''), '[', ''), '.', '_') + 
        '_' + 
        CAST(migs.last_user_seek AS VARCHAR(50)) + 
        ' ON ' + mid.statement + 
        ' (' + ISNULL(mid.equality_columns,'') + 
        CASE WHEN mid.equality_columns IS NOT NULL AND mid.inequality_columns IS NOT NULL THEN ',' ELSE '' END + 
        ISNULL(mid.inequality_columns,'') + 
        ')' + 
        ISNULL(' INCLUDE (' + mid.included_columns + ')', '') AS CreateIndexStatement
FROM sys.dm_db_missing_index_groups mig
JOIN sys.dm_db_missing_index_group_stats migs ON migs.group_handle = mig.index_group_handle
JOIN sys.dm_db_missing_index_details mid ON mig.index_handle = mid.index_handle
WHERE database_id = DB_ID()
ORDER BY Impact DESC;

4. Query Rewriting for Performance:

-- ❌ Bad: Functions on indexed columns
SELECT *
FROM Orders
WHERE YEAR(OrderDate) = 2024 AND MONTH(OrderDate) = 1;
-- Index on OrderDate cannot be used

-- ✅ Good: Range search
SELECT *
FROM Orders
WHERE OrderDate >= '2024-01-01' AND OrderDate < '2024-02-01';
-- Index on OrderDate will be used

-- ❌ Bad: Leading wildcard
SELECT *
FROM Customers
WHERE CustomerName LIKE '%Smith%';
-- Index cannot be used efficiently

-- ✅ Good: Full text search
SELECT *
FROM Customers
WHERE CONTAINS(CustomerName, 'Smith');
-- Full text index used

-- ❌ Bad: OR conditions
SELECT *
FROM Products
WHERE Category = 'Electronics'
   OR Category = 'Computers';
-- May use table scan

-- ✅ Good: IN list or UNION
SELECT *
FROM Products
WHERE Category IN ('Electronics', 'Computers');
-- OR:
SELECT * FROM Products WHERE Category = 'Electronics'
UNION
SELECT * FROM Products WHERE Category = 'Computers';

-- ❌ Bad: Distinct on large table
SELECT DISTINCT CustomerID, OrderDate
FROM Orders;
-- Heavy sort operation

-- ✅ Good: Use EXISTS or GROUP BY
SELECT CustomerID, OrderDate
FROM Orders
GROUP BY CustomerID, OrderDate;

-- ❌ Bad: Subquery with large result
SELECT *
FROM Orders
WHERE CustomerID IN (SELECT CustomerID FROM Customers WHERE Country = 'USA');
-- May be inefficient

-- ✅ Good: JOIN instead
SELECT o.*
FROM Orders o
JOIN Customers c ON o.CustomerID = c.CustomerID
WHERE c.Country = 'USA';

5. Index Design Best Practices:

-- 1. Clustered Index (Primary Key)
CREATE CLUSTERED INDEX PK_Orders ON Orders(OrderID);
-- ✅ Good: Unique, narrow, ever-increasing

-- 2. Non-Clustered Index
CREATE INDEX IX_Orders_OrderDate_Status 
ON Orders(OrderDate, Status)
INCLUDE (CustomerID, TotalAmount);
-- Covered index - includes all needed columns

-- 3. Filtered Index (for specific queries)
CREATE INDEX IX_Orders_Recent 
ON Orders(OrderDate)
WHERE OrderDate > '2024-01-01';
-- Smaller, faster for recent data

-- 4. Include columns for SELECT
CREATE INDEX IX_Orders_Customer_Date
ON Orders(CustomerID, OrderDate)
INCLUDE (Status, TotalAmount, ShippingAddress);
-- All query columns are in the index

-- 5. Index on computed column
ALTER TABLE Orders
ADD OrderMonth AS MONTH(OrderDate) PERSISTED;

CREATE INDEX IX_Orders_Month 
ON Orders(OrderMonth);

6. Analyze Query Cost:

-- Get query execution statistics
DECLARE @queryHash VARCHAR(100);

SELECT 
    qs.sql_handle,
    qs.plan_handle,
    qs.total_worker_time / qs.execution_count AS avg_cpu_time,
    qs.total_elapsed_time / qs.execution_count AS avg_elapsed_time,
    qs.total_logical_reads / qs.execution_count AS avg_logical_reads,
    qs.execution_count,
    SUBSTRING(st.text, (qs.statement_start_offset/2)+1,
        ((CASE qs.statement_end_offset
          WHEN -1 THEN DATALENGTH(st.text)
          ELSE qs.statement_end_offset
         END - qs.statement_start_offset)/2) + 1) AS query_text
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st
WHERE execution_count > 100  -- Frequently executed
ORDER BY total_worker_time DESC;  -- Most CPU

-- Top 10 queries by cost
SELECT TOP 10
    qs.total_worker_time,
    qs.total_elapsed_time,
    qs.total_logical_reads,
    qs.execution_count,
    qs.total_worker_time / qs.execution_count AS avg_cpu,
    SUBSTRING(st.text, (qs.statement_start_offset/2)+1,
        ((CASE qs.statement_end_offset
          WHEN -1 THEN DATALENGTH(st.text)
          ELSE qs.statement_end_offset
         END - qs.statement_start_offset)/2) + 1) AS query_text
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st
ORDER BY qs.total_worker_time DESC;

7. Parameter Sniffing – Hidden Performance Killer:

-- ❌ Parameter sniffing problem
CREATE PROCEDURE GetOrdersByDate
    @StartDate DATE,
    @EndDate DATE
AS
BEGIN
    SELECT *
    FROM Orders
    WHERE OrderDate BETWEEN @StartDate AND @EndDate;
END;

-- First call: 1 row (fast plan)
EXEC GetOrdersByDate '2024-01-01', '2024-01-01';
-- Plan cached for 1 row (Nested Loops)

-- Second call: 1,000,000 rows (slow!)
EXEC GetOrdersByDate '2024-01-01', '2024-12-31';
-- Uses same Nested Loops plan → Performance disaster!

-- ✅ Fix 1: OPTIMIZE FOR UNKNOWN
CREATE PROCEDURE GetOrdersByDate
    @StartDate DATE,
    @EndDate DATE
AS
BEGIN
    SELECT *
    FROM Orders
    WHERE OrderDate BETWEEN @StartDate AND @EndDate
    OPTION (OPTIMIZE FOR UNKNOWN);
END;

-- ✅ Fix 2: Query Hints
SELECT *
FROM Orders
WHERE OrderDate BETWEEN @StartDate AND @EndDate
OPTION (RECOMPILE);  -- Recompile every time

-- ✅ Fix 3: Local variables (no sniffing)
CREATE PROCEDURE GetOrdersByDate
    @StartDate DATE,
    @EndDate DATE
AS
BEGIN
    DECLARE @s DATE = @StartDate;
    DECLARE @e DATE = @EndDate;
    
    SELECT *
    FROM Orders
    WHERE OrderDate BETWEEN @s AND @e;
END;

8. Monitoring and Alerting:

-- Create alert for long-running queries
CREATE EVENT SESSION [LongRunningQueries] ON SERVER
ADD EVENT sqlserver.sql_statement_completed (
    ACTION (sqlserver.sql_text, sqlserver.username)
    WHERE ([duration] >= 10000000)  -- 10 seconds
)
ADD TARGET package0.event_file(SET filename = 'LongRunningQueries.xel')
WITH (MAX_MEMORY=4096 KB, EVENT_RETENTION_MODE=ALLOW_SINGLE_EVENT_LOSS);
-- Start the event session
ALTER EVENT SESSION [LongRunningQueries] ON SERVER STATE = START;

-- Query the event data
SELECT
DATEADD(mi, DATEDIFF(mi, GETUTCDATE(), GETDATE()), event_data.value('(event/@timestamp)[1]', 'datetime2')) AS [Time],
event_data.value('(event/data[@name="duration"]/value)[1]', 'bigint') AS [Duration(ms)],
event_data.value('(event/data[@name="cpu_time"]/value)[1]', 'bigint') AS[CPU(ms)],
event_data.value('(event/data[@name="logical_reads"]/value)[1]', 'bigint') AS[Reads],
event_data.value('(event/data[@name="writes"]/value)[1]', 'bigint') AS[Writes],
event_data.value('(event/action[@name="sql_text"]/value)[1]', 'nvarchar(max)') AS[SQL],
event_data.value('(event/action[@name="username"]/value)[1]', 'nvarchar(100)') AS[User]
FROM(
SELECT CAST(event_data AS XML) AS event_data

FROM sys.fn_xe_file_target_read_file('LongRunningQueries*.xel', NULL, NULL, NULL)
) AS events
ORDER BY[Time] DESC;

Related posts:

Set Identity Insert On/Off in MSSQL

How to hide message window in MS SQL Server

SQL “Blocking Chains” — The Magic of READPAST

Post Views: 4

Post navigation

ASP.NET Core JWT Authentication: Access Tokens, Refresh Tokens, and Security Deep Dive
Step-by-Step: Create ASP.NET Core 8 Web API with Docker and PostgreSQL

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

October 2026
M T W T F S S
 1234
567891011
12131415161718
19202122232425
262728293031  
« Sep    

Most Viewed Posts

  • Get the User Name and Domain Name from an Email Address in SQL (973)
  • How to make theater mode the default for Youtube (952)
  • How to add default value for Entity Framework migrations for DateTime and Bool (939)
  • Get the First and Last Word from a String or Sentence in SQL (847)
  • How to select distinct rows in a datatable in C# (837)
  • How to enable, disable and check if Service Broker is enabled on a database in SQL Server (624)
  • Add Constraint to SQL Table to ensure email contains @ (590)
  • Average of all values in a column that are not zero in SQL (553)
  • How to use Map Mode for Vertical Scroll Mode in Visual Studio (526)
  • Find numbers with more than two decimal places in SQL (468)

Recent Posts

  • CSS: Fix a prefers-color-scheme Media Query That Gets Silently Overridden by a Browser Extension’s Forced Dark Mode
  • Git: Fix a Merge Commit That Silently Drops a File Because Both Branches Deleted It Differently
  • HTML5: Fix a Native Lazy-Loading Image That Never Loads Because It Sits Inside a Hidden Tab Until the User Clicks It
  • The AI Prompt That Traces a Null Reference Exception Back to the Exact Line That First Produced the Null
  • The AI Prompt That Turns a Gym Membership Contract’s Fine Print Into a Plain-English List of Cancellation Steps
  • Photoshop: Fix a Color Profile Mismatch That Makes Printed Output Look Nothing Like What You Saw On Screen
  • WordPress: Fix Search Results That Return Pages From a Theme You Deactivated Months Ago
  • Visual Studio: Fix a Test Project That Builds Fine Alone but Fails to Discover Any Tests After a NuGet Restore
  • ASP.NET Core: Fix a File Upload That Times Out on Slow Connections Only Because Kestrel’s Minimum Data Rate Feature Kicked In
  • JavaScript: Fix an Array Destructuring Default Value That Silently Never Applies Because null Was Passed Instead of Undefined

Most Viewed Posts

  • Get the User Name and Domain Name from an Email Address in SQL (973)
  • How to make theater mode the default for Youtube (952)
  • How to add default value for Entity Framework migrations for DateTime and Bool (939)
  • Get the First and Last Word from a String or Sentence in SQL (847)
  • How to select distinct rows in a datatable in C# (837)

Recent Posts

  • CSS: Fix a prefers-color-scheme Media Query That Gets Silently Overridden by a Browser Extension’s Forced Dark Mode
  • Git: Fix a Merge Commit That Silently Drops a File Because Both Branches Deleted It Differently
  • HTML5: Fix a Native Lazy-Loading Image That Never Loads Because It Sits Inside a Hidden Tab Until the User Clicks It
  • The AI Prompt That Traces a Null Reference Exception Back to the Exact Line That First Produced the Null
  • The AI Prompt That Turns a Gym Membership Contract’s Fine Print Into a Plain-English List of Cancellation Steps

Social

  • ErcanOPAK.com
  • GoodReads
  • LetterBoxD
  • Linkedin
  • The Blog
  • Twitter
© 2026 Bits of .NET | Built with Xblog Plus free WordPress theme by wpthemespace.com