SQL Performance Tutorial

10 SQL Query Optimization Tricks Every Data Analyst Must Know

If you've ever run a query that took 3 minutes to return 5,000 rows โ€” this guide is for you. SQL query optimization is one of the highest-value skills a Data Analyst can have, yet most tutorials gloss over it. I'll share 10 techniques I use every day on real production databases.

Scope

Examples use SQL Server (T-SQL) but most principles apply to MySQL, PostgreSQL, and other relational databases. I'll note exceptions where syntax differs.

1. Use the Execution Plan โ€” Always

Before optimizing anything, read the execution plan. In SSMS: Query โ†’ Include Actual Execution Plan (Ctrl+M), then run. Look for these red flags:

  • Table Scan โ€” reading every row. Needs an index.
  • Key Lookup โ€” fetching extra columns after an index seek. Fix by including columns in the index.
  • High estimated vs actual rows โ€” stale statistics. Run UPDATE STATISTICS tablename.
  • Nested Loop with many iterations โ€” usually an N+1 problem.

2. Never Use SELECT *

This single habit causes more slow queries than almost anything else. Fetching all columns forces SQL Server to read more data pages, move more bytes across the network, and prevents index-only scans.

SQL โ€” Bad vs Good
-- โŒ BAD โ€” reads all 40 columns, forces table scan
SELECT *
FROM dbo.FactSales
WHERE SaleDate >= '2024-01-01';

-- โœ… GOOD โ€” only reads needed columns, can use index
SELECT
    SaleID,
    SaleDate,
    CustomerID,
    TotalAmount
FROM dbo.FactSales
WHERE SaleDate >= '2024-01-01';

3. Write SARGable WHERE Clauses

A SARGable (Search ARGument ABLE) predicate is one that allows the query engine to use an index seek rather than a full scan. The rule: never wrap the column in a function in a WHERE clause.

SQL โ€” SARGable vs Non-SARGable
-- โŒ NOT SARGable โ€” function on column forces full scan
WHERE YEAR(SaleDate) = 2024
WHERE UPPER(CustomerName) = 'ANKIT'
WHERE LEFT(ProductCode, 3) = 'PRD'

-- โœ… SARGable โ€” range on column allows index seek
WHERE SaleDate >= '2024-01-01' AND SaleDate < '2025-01-01'
WHERE CustomerName = 'Ankit'  -- use collation, not UPPER()
WHERE ProductCode LIKE 'PRD%'   -- prefix LIKE is SARGable

4. Index Strategically

The right indexes are the single biggest performance lever. For Data Analyst workloads, focus on:

SQL โ€” Index Types
-- Clustered index: defines physical row order (1 per table)
CREATE CLUSTERED INDEX CIX_FactSales_DateKey
ON dbo.FactSales (SaleDate);

-- Non-clustered index: for frequent filter/join columns
CREATE NONCLUSTERED INDEX NIX_FactSales_CustomerID
ON dbo.FactSales (CustomerID)
INCLUDE (TotalAmount, SaleDate);  -- INCLUDE avoids key lookup!

-- Covering index: all columns needed are IN the index
CREATE NONCLUSTERED INDEX NIX_Sales_Cover_Report
ON dbo.FactSales (SaleDate, RegionID)
INCLUDE (CustomerID, ProductID, TotalAmount, Quantity);
Don't Over-Index

Each index slows down INSERT/UPDATE/DELETE operations. For OLAP-style analytics tables, more indexes are fine. For transactional tables, be selective.

5. Use CTEs to Replace Nested Subqueries

CTEs (Common Table Expressions) don't always improve performance, but they dramatically improve readability โ€” which means fewer bugs and easier maintenance. They're also required for recursive queries.

SQL โ€” CTE vs Subquery
-- โŒ Hard to read nested subquery
SELECT r.RegionName, s.TotalRevenue
FROM (
    SELECT RegionID, SUM(TotalAmount) AS TotalRevenue
    FROM (
        SELECT * FROM FactSales WHERE SaleDate >= '2024-01-01'
    ) y
    GROUP BY RegionID
) s
JOIN DimRegion r ON r.RegionID = s.RegionID;

-- โœ… Clean CTE version โ€” same performance, much clearer
WITH RecentSales AS (
    SELECT RegionID, TotalAmount
    FROM FactSales
    WHERE SaleDate >= '2024-01-01'
),
RegionRevenue AS (
    SELECT RegionID, SUM(TotalAmount) AS TotalRevenue
    FROM RecentSales
    GROUP BY RegionID
)
SELECT r.RegionName, rv.TotalRevenue
FROM RegionRevenue rv
JOIN DimRegion r ON r.RegionID = rv.RegionID;

6. Window Functions Beat Self-Joins

Running totals, rankings, lead/lag comparisons โ€” all of these are traditionally done with slow self-joins. Window functions do the same thing in a single pass.

SQL โ€” Window Functions
SELECT
    SaleDate,
    SalesRepID,
    TotalAmount,

    -- Running total within each sales rep
    SUM(TotalAmount) OVER (
        PARTITION BY SalesRepID
        ORDER BY SaleDate
        ROWS UNBOUNDED PRECEDING
    ) AS RunningTotal,

    -- Rank by revenue within each month
    RANK() OVER (
        PARTITION BY YEAR(SaleDate), MONTH(SaleDate)
        ORDER BY TotalAmount DESC
    ) AS MonthlyRank,

    -- Previous day's sale for the same rep
    LAG(TotalAmount, 1, 0) OVER (
        PARTITION BY SalesRepID ORDER BY SaleDate
    ) AS PrevDaySale

FROM dbo.FactSales;

7. EXISTS > IN for Large Subqueries

SQL โ€” EXISTS vs IN
-- โŒ IN: evaluates the entire subquery first
SELECT CustomerID, CustomerName
FROM DimCustomer
WHERE CustomerID IN (
    SELECT CustomerID FROM FactSales
    WHERE TotalAmount > 10000
);

-- โœ… EXISTS: stops as soon as it finds first match (short-circuits)
SELECT CustomerID, CustomerName
FROM DimCustomer c
WHERE EXISTS (
    SELECT 1  -- SELECT 1 is conventional โ€” we just need the match
    FROM FactSales s
    WHERE s.CustomerID = c.CustomerID
      AND s.TotalAmount > 10000
);

8. Temp Tables vs CTEs for Large Intermediate Results

When an intermediate result set is large (100K+ rows) and referenced multiple times, materialize it into a temp table. CTEs are re-evaluated each time they're referenced.

SQL โ€” Temp Table Pattern
-- Materialize large intermediate result once
DROP TABLE IF EXISTS #SalesSummary;

SELECT
    RegionID,
    ProductID,
    SUM(TotalAmount) AS Revenue,
    SUM(Quantity)    AS Units
INTO #SalesSummary
FROM dbo.FactSales
WHERE SaleDate >= '2024-01-01'
GROUP BY RegionID, ProductID;

-- Create index on temp table for subsequent joins
CREATE INDEX IX_SS_Region ON #SalesSummary (RegionID);

-- Now join multiple times without re-computing
SELECT r.RegionName, s.Revenue
FROM #SalesSummary s
JOIN DimRegion r ON r.RegionID = s.RegionID;

SELECT p.ProductName, s.Units
FROM #SalesSummary s
JOIN DimProduct p ON p.ProductID = s.ProductID;

9. Limit Rows Early with TOP / FETCH

SQL โ€” Pagination
-- Quick top N (no offset)
SELECT TOP (10) ProductName, TotalRevenue
FROM ProductSummary
ORDER BY TotalRevenue DESC;

-- Paginated results (page 2, 50 rows per page)
SELECT SaleID, SaleDate, TotalAmount
FROM dbo.FactSales
ORDER BY SaleDate DESC
OFFSET 50 ROWS
FETCH NEXT 50 ROWS ONLY;

10. Update Statistics & Rebuild Fragmented Indexes

Even perfect queries go slow on stale statistics or fragmented indexes. Schedule this maintenance weekly on large tables:

SQL โ€” Maintenance
-- Check index fragmentation
SELECT
    OBJECT_NAME(ips.object_id) AS TableName,
    i.name                     AS IndexName,
    ips.avg_fragmentation_in_percent
FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'LIMITED') ips
JOIN sys.indexes i
    ON i.object_id = ips.object_id AND i.index_id = ips.index_id
WHERE ips.avg_fragmentation_in_percent > 30  -- >30% = rebuild
ORDER BY ips.avg_fragmentation_in_percent DESC;

-- Rebuild fragmented index
ALTER INDEX NIX_FactSales_CustomerID ON dbo.FactSales REBUILD;

-- Update statistics (run after large data loads)
UPDATE STATISTICS dbo.FactSales WITH FULLSCAN;
Quick Summary

Read execution plans โ†’ Never SELECT * โ†’ Write SARGable predicates โ†’ Index strategically โ†’ Use CTEs for clarity โ†’ Window functions over self-joins โ†’ EXISTS over IN for large sets โ†’ Temp tables for large intermediates โ†’ Paginate results โ†’ Maintain your indexes weekly.


Ankit Kumar

SQL Developer ยท Data Analyst ยท etlguru

5+ years optimizing SQL Server queries for production analytics workloads across manufacturing, trading, and e-commerce businesses. Founder of Pyivot Solutions.