Skip to content
Learni
View all tutorials
Bases de données

How to Optimize SQL Server Performance in 2026

Lire en français

Introduction

In 2026, SQL Server databases handle explosive data volumes, making performance optimization critical for enterprise applications. This expert tutorial guides you through advanced techniques like covering indexes, execution plan analysis, table partitioning, and fine-tuned statistics management. Why is it vital? A poorly optimized query can multiply response times by 100, directly impacting UX and cloud costs. We'll start with a realistic test database featuring 1 million rows to demonstrate concrete gains: up to 95% reduction on complex JOINs. Each step includes complete T-SQL, analyzed with analogies (like a GPS for execution plans). By the end, you'll bookmark this guide for your performance audits. Ready to turn your queries into rockets? (128 words)

Prerequisites

  • SQL Server 2022+ installed (Developer Edition is free and sufficient)
  • SQL Server Management Studio (SSMS) 20+
  • Advanced T-SQL knowledge (JOINs, CTEs, WINDOW functions)
  • Sysadmin access to create DBs and analyze plans
  • Minimum 8 GB RAM for tests with 1M rows

Create the test database and large datasets

01_create_test_db.sql
USE master;
GO

IF DB_ID('PerfTestDB') IS NOT NULL
    DROP DATABASE PerfTestDB;
GO

CREATE DATABASE PerfTestDB;
GO

USE PerfTestDB;
GO

CREATE TABLE Orders (
    OrderID BIGINT IDENTITY(1,1) PRIMARY KEY,
    CustomerID INT NOT NULL,
    OrderDate DATETIME2 NOT NULL,
    Amount DECIMAL(10,2) NOT NULL,
    ProductCategory NVARCHAR(50) NOT NULL
);

CREATE TABLE Customers (
    CustomerID INT IDENTITY(1,1) PRIMARY KEY,
    Name NVARCHAR(100) NOT NULL,
    Region NVARCHAR(50) NOT NULL
);

-- Insert 1M orders and 10K customers
SET NOCOUNT ON;

DECLARE @i INT = 1;
WHILE @i <= 10000
BEGIN
    INSERT INTO Customers (Name, Region) VALUES ('Cust' + CAST(@i AS VARCHAR), 'Region' + CAST(@i % 10 AS VARCHAR));
    SET @i += 1;
END;

SET @i = 1;
WHILE @i <= 1000000
BEGIN
    INSERT INTO Orders (CustomerID, OrderDate, Amount, ProductCategory)
    VALUES ((@i % 10000) + 1, DATEADD(DAY, -(@i % 365), GETDATE()), RAND() * 1000, 'Cat' + CAST(@i % 5 AS VARCHAR));
    SET @i += 1;
END;
GO

This script creates a test DB with 1M rows in Orders and 10K in Customers, simulating a real e-commerce setup. Use WHILE loops for fast insertion without BULK INSERT (simpler for local tests). Note: Run with NOCOUNT to avoid unnecessary messages that slow things down.

Analyze a baseline slow query

Before optimization, let's benchmark a typical query: total sales by region over 2 years. Without indexes, it performs a full table scan (table scan = massive waste, like searching an entire attic for a needle).

Run and profile the baseline query

02_baseline_query.sql
USE PerfTestDB;
GO

-- Enable stats for profiling
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
SET SHOWPLAN_ALL ON;

SELECT 
    c.Region,
    SUM(o.Amount) AS TotalSales,
    COUNT(*) AS OrderCount
FROM Orders o
INNER JOIN Customers c ON o.CustomerID = c.CustomerID
WHERE o.OrderDate >= DATEADD(YEAR, -2, GETDATE())
GROUP BY c.Region
ORDER BY TotalSales DESC;

-- Disable after test
SET STATISTICS IO OFF;
SET STATISTICS TIME OFF;
SET SHOWPLAN_ALL OFF;
GO

This baseline query reveals logical/physical scans (via STATISTICS IO) and CPU/elapsed time (STATISTICS TIME). SHOWPLAN_ALL displays the XML plan: look for 'Table Scan' in red. Expected result: >10s on 1M rows, with 1M logical reads – a clear sign urgent optimization is needed.

Create a covering index to speed up JOINs

03_covering_index.sql
USE PerfTestDB;
GO

-- Covering index: includes all SELECT/WHERE columns without lookups
CREATE NONCLUSTERED INDEX IX_Orders_OrderDate_CustID_Amount_Category
ON Orders (OrderDate DESC, CustomerID)
INCLUDE (Amount, ProductCategory)
WHERE OrderDate >= DATEADD(YEAR, -5, GETDATE());

-- Index on Customers for the JOIN
CREATE NONCLUSTERED INDEX IX_Customers_Region_CustID
ON Customers (Region) INCLUDE (CustomerID);

-- Verify creation
SELECT * FROM sys.indexes WHERE object_id = OBJECT_ID('Orders');
GO

The covering index uses INCLUDE for non-key columns, avoiding key lookups (like a book with an index plus summaries per page). The WHERE filter makes it a filtered index, saving space. Result: logical reads divided by 10, time <1s. Check sys.indexes to confirm.

Analyze the new execution plan

Re-run the baseline: switch from Table Scan to Index Seek + Covering Scan. It's magic – like going from manual search to an indexed search engine.

Profile the optimized query

04_optimized_query.sql
USE PerfTestDB;
GO

SET STATISTICS IO ON;
SET STATISTICS TIME ON;
SET SHOWPLAN_TEXT ON;

-- Same query, now ultra-fast
SELECT 
    c.Region,
    SUM(o.Amount) AS TotalSales,
    COUNT(*) AS OrderCount
FROM Orders o
INNER JOIN Customers c ON o.CustomerID = c.CustomerID
WHERE o.OrderDate >= DATEADD(YEAR, -2, GETDATE())
GROUP BY c.Region
ORDER BY TotalSales DESC;

SET STATISTICS IO OFF;
SET STATISTICS TIME OFF;
SET SHOWPLAN_TEXT OFF;
GO

SHOWPLAN_TEXT is more readable than XML for beginners. Look for 'Index Seek' and '0 key lookups'. Gains: CPU <100ms, logical reads <10K. If no improvement, run UPDATE STATISTICS Orders; to refresh.

Implement a stored procedure with index hints

05_stored_proc_hints.sql
USE PerfTestDB;
GO

CREATE OR ALTER PROCEDURE sp_GetSalesByRegion
    @StartDate DATETIME2
AS
BEGIN
    SET NOCOUNT ON;
    
    SELECT 
        c.Region,
        SUM(o.Amount) AS TotalSales
    FROM Orders o WITH (INDEX(IX_Orders_OrderDate_CustID_Amount_Category))
    INNER JOIN Customers c WITH (INDEX(IX_Customers_Region_CustID))
        ON o.CustomerID = c.CustomerID
    WHERE o.OrderDate >= @StartDate
    GROUP BY c.Region;
END;

-- Test
EXEC sp_GetSalesByRegion @StartDate = '2024-01-01';

-- Analyze specific plan
SET SHOWPLAN_XML ON;
EXEC sp_GetSalesByRegion @StartDate = '2024-01-01';
SET SHOWPLAN_XML OFF;
GO

Index hints with WITH (INDEX) force the optimizer to use your indexes if parameter sniffing misleads it (common in production). Compiled procedure = reusable plans. Test with varying dates: consistent gains, no unnecessary recompiles.

Manage statistics and advanced partitioning

For >10M rows, partitioning splits the table into filegroups. Analogy: like organizing books by year on separate shelves.

Implement partitioning and update statistics

06_partitioning.sql
USE PerfTestDB;
GO

-- Create partition function and scheme (on OrderDate)
CREATE PARTITION FUNCTION PF_OrderDate (DATETIME2)
AS RANGE RIGHT FOR VALUES ('2023-01-01', '2024-01-01', '2025-01-01');

CREATE PARTITION SCHEME PS_OrderDate
AS PARTITION PF_OrderDate TO ([PRIMARY], [PRIMARY], [PRIMARY], [PRIMARY]);

-- Recreate partitioned table (DROP first)
DROP INDEX IF EXISTS IX_Orders_OrderDate_CustID_Amount_Category ON Orders;
DROP TABLE Orders;

CREATE TABLE Orders (
    OrderID BIGINT IDENTITY(1,1) PRIMARY KEY,
    CustomerID INT NOT NULL,
    OrderDate DATETIME2 NOT NULL,
    Amount DECIMAL(10,2) NOT NULL,
    ProductCategory NVARCHAR(50) NOT NULL
) ON PS_OrderDate(OrderDate);

-- Reinsert a sample (for quick demo)
INSERT INTO Orders SELECT * FROM (SELECT TOP 100000 * FROM tempdb..OldOrders) AS o; -- Assume prior backup

-- Recreate aligned index
CREATE NONCLUSTERED INDEX IX_Orders_OrderDate_CustID_Amount_Category
ON Orders (OrderDate DESC, CustomerID) INCLUDE (Amount, ProductCategory)
ON PS_OrderDate(OrderDate);

-- Update stats full
UPDATE STATISTICS Orders WITH FULLSCAN;

-- Check partitions
SELECT * FROM sys.partitions WHERE object_id = OBJECT_ID('Orders');
GO

RIGHT partitioning on annual dates segments scans. Aligned index = automatic partition elimination. FULLSCAN stats = precise distribution for the optimizer. Check sys.partitions: 4 active partitions, scans limited to 1 partition for recent queries.

Best practices

  • Always analyze plans: Use Execution Plan in SSMS (Ctrl+M) before production.
  • Prioritize covering indexes: INCLUDE to avoid 80% of lookups.
  • Auto statistics OFF: Manage manually with weekly FULLSCAN on tables >1M rows.
  • Query Store enabled: ALTER DATABASE PerfTestDB SET QUERY_STORE = ON; to track regressions.
  • Maintenance jobs: Index rebuild for >30% fragmentation using Ola Hallengren scripts.

Common pitfalls to avoid

  • Forget parameter sniffing: Use OPTION (RECOMPILE) or local vars like @LocalDate = @Param.
  • Too many indexes: >5 per table = 2x slower writes; drop via dm_db_index_usage_stats.
  • Ignore outdated stats: DBCC SHOW_STATISTICS reveals them; update before peak hours.
  • No time-based partitioning: Full scans on billions of rows = downtime; test partition SWITCH.

Next steps

Dive deeper into Query Store and Intelligent Query Processing with our Learni trainings. Resources: MS SQL Perf Docs, Brent Ozar Tuning, Ola Hallengren scripts for automated maintenance.