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
| EmployeeID | FirstName | Salary | DepartmentID |
| 1 | Rajesh | 250000.00 | 1 |
| 3 | Vikram | 190000.00 | 4 |
| 2 | Anita | 180000.00 | 2 |
| 5 | Suresh | 130000.00 | 6 |
| 4 | Meena | 120000.00 | 5 |
| 6 | Kavya | 110000.00 | 3 |
| 21 | Deepak | 105000.00 | 7 |
| 7 | Arjun | 100000.00 | 2 |
| 11 | Kiran | 95000.00 | 4 |
| 15 | Sanjay | 90000.00 | 4 |
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
| DepartmentName | EmpCount | AvgSalary |
| Executive | 1 | 250000.000000 |
| Sales | 6 | 83333.333333 |
| Marketing | 3 | 67333.333333 |
| IT | 7 | 94000.000000 |
| HR | 2 | 82500.000000 |
| Finance | 3 | 84000.000000 |
| Operations | 2 | 72500.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
| CustomerName | Orders | Revenue | Tier |
| Lakshmi Stores | 13 | 1925592.50 | Gold |
| Pune Tech Park | 8 | 1649975.00 | Gold |
| Ravi Traders | 11 | 1527910.00 | Gold |
| Marina Digital | 6 | 1326609.00 | Gold |
| Infosoft Solutions | 5 | 839225.00 | Silver |
| Kerala Kart | 3 | 644925.00 | Silver |
| Awadh Enterprises | 4 | 623885.00 | Silver |
| Silicon Valley Pvt Ltd | 3 | 574857.00 | Silver |
| Shivaji Stationers | 4 | 548650.00 | Silver |
| Pink City Furnishers | 4 | 536100.00 | Silver |
| Gateway Retail | 1 | 476850.00 | Silver |
| Mumbai Mart | 4 | 365735.00 | Bronze |
| Northern Distributors | 2 | 275200.00 | Bronze |
| Bengal Book House | 2 | 259075.00 | Bronze |
| Gujarat Office Supply | 3 | 148800.00 | Bronze |
... 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
| SaleMonth | Revenue | AvgMonthlyRevenue | Flag |
| 2025-01-01 | 2609130.00 | 2605615.000000 | Above Avg |
| 2025-02-01 | 1678350.00 | 2605615.000000 | Below Avg |
| 2025-03-01 | 3100570.00 | 2605615.000000 | Above Avg |
| 2025-04-01 | 3223180.00 | 2605615.000000 | Above Avg |
| 2025-05-01 | 1898440.00 | 2605615.000000 | Below Avg |
| 2025-06-01 | 3124020.00 | 2605615.000000 | Above 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
| CategoryName | ProductName | Revenue | rn |
| Accessories | 27 inch Monitor | 510300.00 | 1 |
| Accessories | Wireless Mouse | 84660.00 | 2 |
| Accessories | Mechanical Keyboard | 69250.00 | 3 |
| Desks | Standing Desk | 325600.00 | 1 |
| Desktops | Lenovo ThinkCentre | 1438250.00 | 1 |
| Desktops | Acer Aspire Desktop | 915750.00 | 2 |
| Laptops | MacBook Air M3 | 3408000.00 | 1 |
| Laptops | HP Pavilion 14 | 1938000.00 | 2 |
| Laptops | Dell Inspiron 15 | 828750.00 | 3 |
| Mobiles | iPhone 15 | 1774500.00 | 1 |
| Mobiles | Samsung Galaxy A55 | 355000.00 | 2 |
| Mobiles | Redmi Note 13 | 337500.00 | 3 |
| Office Chairs | Ergonomic Office Chair | 201000.00 | 1 |
| Office Chairs | Basic Office Chair | 171700.00 | 2 |
| Stationery | Desk Organizer | 20520.00 | 1 |
... 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
| ContactID | FullName | Email | CreatedOn | rn |
| 8 | Arun Prakash | arun@mail.com | 2025-01-06 13:10:00 | 1 |
| 7 | Fatima Khan | fatima@mail.com | 2025-01-04 16:20:00 | 1 |
| 6 | John Peter | john@mail.com | 2025-03-01 14:45:00 | 1 |
| 5 | John Peter | john@mail.com | 2025-02-10 08:15:00 | 2 |
| 4 | John Peter | john@mail.com | 2025-01-03 12:00:00 | 3 |
| 2 | Ravi Kumar | ravi@mail.com | 2025-01-05 09:30:00 | 1 |
| 1 | Ravi Kumar | ravi@mail.com | 2025-01-01 10:00:00 | 2 |
| 9 | Sita Devi | sita@mail.com | 2025-04-11 10:05:00 | 1 |
| 3 | Sita Devi | sita@mail.com | 2025-01-02 11:00:00 | 2 |
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
| ContactID | FullName | Email | Phone | CreatedOn |
| 2 | Ravi Kumar | ravi@mail.com | 9000000001 | 2025-01-05 09:30:00 |
| 6 | John Peter | john@mail.com | 9000000003 | 2025-03-01 14:45:00 |
| 7 | Fatima Khan | fatima@mail.com | 9000000004 | 2025-01-04 16:20:00 |
| 8 | Arun Prakash | arun@mail.com | 9000000005 | 2025-01-06 13:10:00 |
| 9 | Sita Devi | sita@mail.com | 9000000002 | 2025-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
| EmployeeID | FirstName | DepartmentID | Salary |
| 24 | Naveen | NULL | 20000.00 |
| 1 | Rajesh | 1 | 262500.00 |
| 9 | Rahul | 2 | 54600.00 |
| 8 | Pooja | 2 | 55000.00 |
| 23 | Ritu | 2 | 55000.00 |
| 10 | Sneha | 2 | 58000.00 |
| 7 | Arjun | 2 | 100000.00 |
| 2 | Anita | 2 | 180000.00 |
| 20 | Swati | 3 | 44100.00 |
| 19 | Rohit | 3 | 50000.00 |
| 6 | Kavya | 3 | 110000.00 |
| 12 | Divya | 4 | 70000.00 |
| 25 | Geeta | 4 | 70000.00 |
| 13 | Amit | 4 | 71400.00 |
| 14 | Neha | 4 | 75000.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
- Using a CTE, find the month with the highest sales in each Region.
- Using two CTEs, list sales people whose 2025 sales are above the team average.
- Find customers whose first order was Cancelled.
- Re-insert duplicate rows into CustomerContacts and remove them keeping the OLDEST row.
- Show categories whose revenue share is more than 20% of total revenue.