Back to portfolio
SQL Lab

Query optimization SQL Server

Case study focused on identifying performance bottlenecks, analyzing the root cause, and restructuring a query to improve its efficiency.

01

Business question

How can we identify the customers who generated the most revenue in the last 90 days without compromising dashboard response time?

02

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.

03

Original query

Query used before optimization.

Issue
SQL
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
04

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:

SQL Diagnostics
-- 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

05

Issues found

01

CONVERT

Using CONVERT directly on SaleDate makes it harder for SQL Server to efficiently use existing indexes.

02

UPPER

Applying UPPER in the filter adds processing overhead and can reduce the efficiency of search strategies.

03

INDEX

The query relied on columns frequently used in filters, joins, and aggregation.

06

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.

SQL
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.

SQL Index evaluated
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
);
06

Adjustment - UPPER, CONVERT

Query restructuring while monitoring performance.

Solution
SQL
-- 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

07

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.

Before 796 ms

Original query

After 286 ms

Optimized query

Improvement 64.1%

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%.

08

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.

SARGability Execution Plan Indexing STATISTICS IO Performance Tuning
Back to portfolio