Skip to content

Common Table Expressions (CTE) in SQL Server

A CTE is a named, temporary result set that lives only for ONE statement.

WITH CteName (optional column list) AS
(
    SELECT ...
)
SELECT * FROM CteName;

Why use CTEs? Readability, reuse the same result twice in one query, step-by-step logic, recursion (next file), update/delete via CTE.

Run the sample database script first. Every query below was run on that data, and the Output under each query is the real result.

USE SqlLearningDb;
GO

Topic table: CustomerContacts with Duplicates (for de-duplication demo)

DROP TABLE IF EXISTS dbo.CustomerContacts;

CREATE TABLE dbo.CustomerContacts
(
    ContactID   INT IDENTITY(1,1) PRIMARY KEY,
    FullName    VARCHAR(50)  NOT NULL,
    Email       VARCHAR(100) NOT NULL,
    Phone       VARCHAR(20)  NULL,
    CreatedOn   DATETIME2    NOT NULL
);

INSERT INTO dbo.CustomerContacts (FullName, Email, Phone, CreatedOn) VALUES
('Ravi Kumar',   'ravi@mail.com',   '9000000001', '2025-01-01 10:00'),
('Ravi Kumar',   'ravi@mail.com',   '9000000001', '2025-01-05 09:30'),   -- duplicate
('Sita Devi',    'sita@mail.com',   '9000000002', '2025-01-02 11:00'),
('John Peter',   'john@mail.com',   NULL,         '2025-01-03 12:00'),
('John Peter',   'john@mail.com',   '9000000003', '2025-02-10 08:15'),   -- duplicate (newer)
('John Peter',   'john@mail.com',   '9000000003', '2025-03-01 14:45'),   -- duplicate (newest)
('Fatima Khan',  'fatima@mail.com', '9000000004', '2025-01-04 16:20'),
('Arun Prakash', 'arun@mail.com',   '9000000005', '2025-01-06 13:10'),
('Sita Devi',    'sita@mail.com',   '9000000002', '2025-04-11 10:05');   -- duplicate
GO
Messages
(9 rows affected)

Part A: Basic CTE

Simple CTE: high earners

WITH HighEarners AS
(
    SELECT EmployeeID, FirstName, Salary, DepartmentID
    FROM dbo.Employees
    WHERE Salary >= 90000
)
SELECT * FROM HighEarners ORDER BY Salary DESC;
Output
EmployeeIDFirstNameSalaryDepartmentID
1Rajesh250000.001
3Vikram190000.004
2Anita180000.002
5Suresh130000.006
4Meena120000.005
6Kavya110000.003
21Deepak105000.007
7Arjun100000.002
11Kiran95000.004
15Sanjay90000.004

CTE with column list

WITH DeptSalary (DepartmentID, EmpCount, AvgSalary) AS
(
    SELECT DepartmentID, COUNT(*), AVG(Salary)
    FROM dbo.Employees
    GROUP BY DepartmentID
)
SELECT d.DepartmentName, ds.EmpCount, ds.AvgSalary
FROM DeptSalary ds
JOIN dbo.Departments d ON d.DepartmentID = ds.DepartmentID;
Output
DepartmentNameEmpCountAvgSalary
Executive1250000.000000
Sales683333.333333
Marketing367333.333333
IT794000.000000
HR282500.000000
Finance384000.000000
Operations272500.000000

Part B: Multiple CTEs (chained, step by step logic)

Step 1: order totals -> Step 2: customer totals -> Step 3: classify customers

WITH OrderTotals AS
(
    SELECT o.OrderID, o.CustomerID, SUM(oi.LineTotal) AS OrderTotal
    FROM dbo.Orders o
    JOIN dbo.OrderItems oi ON oi.OrderID = o.OrderID
    WHERE o.Status <> 'Cancelled'
    GROUP BY o.OrderID, o.CustomerID
),
CustomerTotals AS
(
    SELECT CustomerID, COUNT(*) AS Orders, SUM(OrderTotal) AS Revenue
    FROM OrderTotals
    GROUP BY CustomerID
),
Classified AS
(
    SELECT ct.*,
           CASE WHEN Revenue >= 1000000 THEN 'Gold'
                WHEN Revenue >=  400000 THEN 'Silver'
                ELSE 'Bronze' END AS Tier
    FROM CustomerTotals ct
)
SELECT c.CustomerName, cl.Orders, cl.Revenue, cl.Tier
FROM Classified cl
JOIN dbo.Customers c ON c.CustomerID = cl.CustomerID
ORDER BY cl.Revenue DESC;
Output
CustomerNameOrdersRevenueTier
Lakshmi Stores131925592.50Gold
Pune Tech Park81649975.00Gold
Ravi Traders111527910.00Gold
Marina Digital61326609.00Gold
Infosoft Solutions5839225.00Silver
Kerala Kart3644925.00Silver
Awadh Enterprises4623885.00Silver
Silicon Valley Pvt Ltd3574857.00Silver
Shivaji Stationers4548650.00Silver
Pink City Furnishers4536100.00Silver
Gateway Retail1476850.00Silver
Mumbai Mart4365735.00Bronze
Northern Distributors2275200.00Bronze
Bengal Book House2259075.00Bronze
Gujarat Office Supply3148800.00Bronze
... showing 15 of 17 rows

Use the same CTE Twice: compare each month with company monthly average

WITH MonthlySales AS
(
    SELECT DATEFROMPARTS(YEAR(SaleDate), MONTH(SaleDate), 1) AS SaleMonth,
           SUM(TotalAmount) AS Revenue
    FROM dbo.Sales
    GROUP BY DATEFROMPARTS(YEAR(SaleDate), MONTH(SaleDate), 1)
)
SELECT m.SaleMonth, m.Revenue,
       (SELECT AVG(Revenue) FROM MonthlySales) AS AvgMonthlyRevenue,
       CASE WHEN m.Revenue > (SELECT AVG(Revenue) FROM MonthlySales) THEN 'Above Avg' ELSE 'Below Avg' END AS Flag
FROM MonthlySales m
ORDER BY m.SaleMonth;
Output
SaleMonthRevenueAvgMonthlyRevenueFlag
2025-01-012609130.002605615.000000Above Avg
2025-02-011678350.002605615.000000Below Avg
2025-03-013100570.002605615.000000Above Avg
2025-04-013223180.002605615.000000Above Avg
2025-05-011898440.002605615.000000Below Avg
2025-06-013124020.002605615.000000Above Avg

Top 3 products by revenue in each category (CTE + ROW_NUMBER)

WITH ProductRevenue AS
(
    SELECT p.CategoryID, p.ProductName, SUM(oi.LineTotal) AS Revenue
    FROM dbo.OrderItems oi
    JOIN dbo.Products p ON p.ProductID = oi.ProductID
    GROUP BY p.CategoryID, p.ProductName
),
Ranked AS
(
    SELECT *, ROW_NUMBER() OVER (PARTITION BY CategoryID ORDER BY Revenue DESC) AS rn
    FROM ProductRevenue
)
SELECT c.CategoryName, r.ProductName, r.Revenue, r.rn
FROM Ranked r
JOIN dbo.Categories c ON c.CategoryID = r.CategoryID
WHERE r.rn <= 3
ORDER BY c.CategoryName, r.rn;
Output
CategoryNameProductNameRevenuern
Accessories27 inch Monitor510300.001
AccessoriesWireless Mouse84660.002
AccessoriesMechanical Keyboard69250.003
DesksStanding Desk325600.001
DesktopsLenovo ThinkCentre1438250.001
DesktopsAcer Aspire Desktop915750.002
LaptopsMacBook Air M33408000.001
LaptopsHP Pavilion 141938000.002
LaptopsDell Inspiron 15828750.003
MobilesiPhone 151774500.001
MobilesSamsung Galaxy A55355000.002
MobilesRedmi Note 13337500.003
Office ChairsErgonomic Office Chair201000.001
Office ChairsBasic Office Chair171700.002
StationeryDesk Organizer20520.001
... showing 15 of 17 rows

Part C: Modify Data Through a CTE

Find duplicates (keep the Newest row per Email)

WITH Dups AS
(
    SELECT ContactID, FullName, Email, CreatedOn,
           ROW_NUMBER() OVER (PARTITION BY Email ORDER BY CreatedOn DESC) AS rn
    FROM dbo.CustomerContacts
)
SELECT * FROM Dups ORDER BY Email, rn;
Output
ContactIDFullNameEmailCreatedOnrn
8Arun Prakasharun@mail.com2025-01-06 13:10:001
7Fatima Khanfatima@mail.com2025-01-04 16:20:001
6John Peterjohn@mail.com2025-03-01 14:45:001
5John Peterjohn@mail.com2025-02-10 08:15:002
4John Peterjohn@mail.com2025-01-03 12:00:003
2Ravi Kumarravi@mail.com2025-01-05 09:30:001
1Ravi Kumarravi@mail.com2025-01-01 10:00:002
9Sita Devisita@mail.com2025-04-11 10:05:001
3Sita Devisita@mail.com2025-01-02 11:00:002

DELETE duplicates through the CTE (very popular interview question)

WITH Dups AS
(
    SELECT ContactID,
           ROW_NUMBER() OVER (PARTITION BY Email ORDER BY CreatedOn DESC) AS rn
    FROM dbo.CustomerContacts
)
DELETE FROM Dups WHERE rn > 1;

SELECT * FROM dbo.CustomerContacts;   -- now only unique emails
Messages
(4 rows affected)
Output
ContactIDFullNameEmailPhoneCreatedOn
2Ravi Kumarravi@mail.com90000000012025-01-05 09:30:00
6John Peterjohn@mail.com90000000032025-03-01 14:45:00
7Fatima Khanfatima@mail.com90000000042025-01-04 16:20:00
8Arun Prakasharun@mail.com90000000052025-01-06 13:10:00
9Sita Devisita@mail.com90000000022025-04-11 10:05:00

UPDATE through a CTE: give 5% raise to the lowest paid person in each department

BEGIN TRAN;
    WITH Lowest AS
    (
        SELECT EmployeeID, Salary,
               ROW_NUMBER() OVER (PARTITION BY DepartmentID ORDER BY Salary) AS rn
        FROM dbo.Employees
        WHERE DepartmentID IS NOT NULL
    )
    UPDATE Lowest SET Salary = Salary * 1.05 WHERE rn = 1;

    SELECT EmployeeID, FirstName, DepartmentID, Salary FROM dbo.Employees ORDER BY DepartmentID, Salary;
ROLLBACK TRAN;   -- undo so data stays the same for other scripts
Messages
(7 rows affected)
Output
EmployeeIDFirstNameDepartmentIDSalary
24NaveenNULL20000.00
1Rajesh1262500.00
9Rahul254600.00
8Pooja255000.00
23Ritu255000.00
10Sneha258000.00
7Arjun2100000.00
2Anita2180000.00
20Swati344100.00
19Rohit350000.00
6Kavya3110000.00
12Divya470000.00
25Geeta470000.00
13Amit471400.00
14Neha475000.00
... showing 15 of 25 rows

Part D: CTE vs Derived Table vs Temp Table

- CTE        : one statement only, not stored, can be referenced many times, can be recursive
- Derived    : same as CTE but inline, cannot be referenced twice
- Temp table : stored in tempdb, can be indexed, used across many statements (see file 06)
NOTE: The statement BEFORE a WITH must end with a semicolon ( ; ).

Practice Questions

  1. Using a CTE, find the month with the highest sales in each Region.
  2. Using two CTEs, list sales people whose 2025 sales are above the team average.
  3. Find customers whose first order was Cancelled.
  4. Re-insert duplicate rows into CustomerContacts and remove them keeping the OLDEST row.
  5. Show categories whose revenue share is more than 20% of total revenue.