Query optimization SQL Server
Case study focused on identifying performance bottlenecks, analyzing the root cause, and restructuring a query to improve its efficiency.
Business question
How can we identify the customers who generated the most revenue in the last 90 days without compromising dashboard response time?
Problem context
A query used to feed a sales report showed a progressive increase in execution time as sales volume grew.
The goal of the investigation was to identify which operations were causing the highest cost in SQL Server and reduce processing without changing the expected business result.
Original query
Query used before optimization.
SELECT
c.CustomerKey, -- Uniquely identifies each customer
c.CustomerName, -- Displays the customer name in the result
c.Segment, -- Keeps the customer segment available for analysis
SUM(s.TotalAmount) AS Revenue90Days, -- Sums revenue generated in the last 90 days
COUNT(s.SaleKey) AS SalesCount -- Counts how many sales the customer made
FROM gold.DimCustomer c
INNER JOIN gold.FactSales s
ON s.CustomerKey = c.CustomerKey -- Links each sale to its respective customer
WHERE
CONVERT(DATE, s.SaleDate) >=
CONVERT(DATE, DATEADD(DAY, -90, GETDATE()))
-- Keeps only sales from the last 90 days.
-- CONVERT on the SaleDate column is one of the points evaluated during optimization.
AND UPPER(s.Status) = 'APPROVED'
-- Includes only approved sales.
-- UPPER on the column is also evaluated because it adds processing overhead.
GROUP BY
c.CustomerKey,
c.CustomerName,
c.Segment -- Groups sales to calculate totals by customer
ORDER BY
Revenue90Days DESC; -- Shows customers with the highest revenue first
Investigation
Before changing the query, the first step was to measure its behavior using SQL Server statistics. The metrics were enabled and disabled as shown below:
-- Shows how many data pages were read during the query
SET STATISTICS IO ON;
-- Shows CPU time and total execution time
SET STATISTICS TIME ON;
-- Run the original query here to measure its behavior
-- Disables statistics after the test
SET STATISTICS IO OFF;
SET STATISTICS TIME OFF;
After running the query with STATISTICS IO and STATISTICS TIME enabled, SQL Server displays metrics in the "Messages" tab that help evaluate query cost. Rows Returned represents the number of rows returned by the result; Logical Reads indicates how many data pages SQL Server had to access in memory to process the query; and Execution Time shows processor time (CPU Time) and total elapsed time (Elapsed Time). Times are shown in milliseconds (ms).
Rows Returned
The original query returned 16,449 rows in the analyzed dataset.
Logical Reads
FactSales: 6,618 logical reads and DimCustomer: 279 logical reads.
Execution Time
CPU: 484 ms
Elapsed time: 796 ms
Issues found
CONVERT
Using CONVERT directly on SaleDate makes it harder for SQL Server to efficiently use existing indexes.
UPPER
Applying UPPER in the filter adds processing overhead and can reduce the efficiency of search strategies.
INDEX
The query relied on columns frequently used in filters, joins, and aggregation.
Adjustment - INDEX
Before proposing a new index, the first step was to review the indexes already available on gold.FactSales. This avoids redundant structures and helps determine whether an existing index already supports the columns used by the query.
SELECT
i.name AS IndexName, -- Name of the index found
i.type_desc AS IndexType, -- Indicates whether the index is clustered or nonclustered
i.is_unique AS IsUnique, -- Shows whether the index requires unique values
c.name AS ColumnName, -- Column that is part of the index
ic.key_ordinal AS KeyOrder, -- Column order within the index key
ic.is_included_column AS IsIncludedColumn -- Indicates whether the column is only in INCLUDE
FROM sys.indexes i
INNER JOIN sys.index_columns ic
ON i.object_id = ic.object_id
AND i.index_id = ic.index_id -- Links the index to its columns
INNER JOIN sys.columns c
ON ic.object_id = c.object_id
AND ic.column_id = c.column_id -- Retrieves the name of each column
WHERE
i.object_id = OBJECT_ID('gold.FactSales') -- Analyzes only the table used in the test
AND i.is_hypothetical = 0 -- Ignores hypothetical indexes created by the optimizer
ORDER BY
i.name,
ic.is_included_column,
ic.key_ordinal,
ic.index_column_id; -- Orders the result to make it easier to read
If the review confirms that there is no suitable index for the query's filter and join pattern, the following strategy can then be tested.
CREATE NONCLUSTERED INDEX IX_FactSales_Status_SaleDate_CustomerKey
ON gold.FactSales (
Status, -- Used in the filter to keep only APPROVED sales
SaleDate, -- Used in the last 90 days filter
CustomerKey -- Used in the join with DimCustomer
)
INCLUDE (
TotalAmount -- Needed to calculate revenue without looking up the column outside the index
);
Adjustment - UPPER, CONVERT
Query restructuring while monitoring performance.
-- Enables metrics to compare the new version with the original query
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
GO
-- Calculates the start date once, outside the table filter
DECLARE @StartDate DATE;
SET @StartDate = DATEADD(DAY, -90, GETDATE());
SELECT
c.CustomerKey, -- Uniquely identifies each customer
c.CustomerName, -- Displays the customer name in the result
c.Segment, -- Keeps the segment for analysis
SUM(s.TotalAmount) AS Revenue90Days, -- Sums revenue from the last 90 days
COUNT(*) AS SalesCount -- Counts the sales found for each customer
FROM gold.FactSales s
INNER JOIN gold.DimCustomer c
ON c.CustomerKey = s.CustomerKey -- Links the sale to the customer
WHERE
s.SaleDate >= @StartDate
-- Filters directly on SaleDate without applying CONVERT to the column
AND s.Status = 'APPROVED'
-- Filters directly by status without applying UPPER to the column
GROUP BY
c.CustomerKey,
c.CustomerName,
c.Segment -- Aggregates revenue and sales count by customer
ORDER BY
Revenue90Days DESC; -- Prioritizes customers who generated the most revenue
GO
-- Disables metrics after the test
SET STATISTICS IO OFF;
SET STATISTICS TIME OFF;
GO
Rows Returned
The optimized version returned 16,449 rows in the test.
Logical Reads
FactSales: 477 logical reads and DimCustomer: 279 logical reads.
Execution Time
CPU: 125 ms
Elapsed time: 286 ms
Results
Elapsed time, CPU time, and logical reads were compared in the same test environment. Both versions returned 16,449 rows, preserving the same result for the comparison.
Original query
Optimized query
Elapsed time reduction
FactSales Reads
6,618 → 477
Reduction of 92.8%.
CPU Time
484 ms → 125 ms
Reduction of 74.2%.
Elapsed Time
796 ms → 286 ms
Reduction of 64.1%.
Conclusion
The tests showed that simple adjustments can make a significant difference. Query execution time dropped from 796 ms to 286 ms, while FactSales logical reads decreased from 6,618 to 477.
More important than looking only at execution time was measuring what SQL Server was actually reading and processing. It is also important to review existing indexes before creating a new one. This turns optimization from trial and error into a decision based on the database's own metrics.
Back to portfolio