CTEs in SQL Server: WITH, recursive queries and the performance myth
What a common table expression is, how to write recursive CTEs for hierarchies and date series, MAXRECURSION, and why a CTE used twice is computed twice.
A common table expression (CTE) is a named query you define with WITH and then use in the statement that follows, like a temporary view that exists for one statement. CTEs make long queries readable, and recursive CTEs walk hierarchies. They do not store their result, and that’s the part people get wrong.
The examples run against the SpDevDemo database.
Syntax
WITH customer_spend AS (
SELECT CustomerId, COUNT(*) AS Orders, SUM(Total) AS Spent
FROM dbo.Orders
GROUP BY CustomerId
)
SELECT TOP (5) c.Name, s.Orders, s.Spent
FROM customer_spend AS s
JOIN dbo.Customers AS c ON c.CustomerId = s.CustomerId
ORDER BY s.Spent DESC;
- The CTE exists only for the single statement right after it.
- If the
WITHfollows another statement in the batch, that statement must end with a semicolon. That’s why you see;WITHin so much code: a defensive semicolon. - Several CTEs are separated by commas, and each can use the ones before it:
WITH monthly AS (
SELECT DATEFROMPARTS(YEAR(OrderDate), MONTH(OrderDate), 1) AS Month, SUM(Total) AS Revenue
FROM dbo.Orders
GROUP BY DATEFROMPARTS(YEAR(OrderDate), MONTH(OrderDate), 1)
),
with_previous AS (
SELECT Month, Revenue, LAG(Revenue) OVER (ORDER BY Month) AS PreviousRevenue
FROM monthly
)
SELECT TOP (6) Month, Revenue,
CAST(100.0 * (Revenue - PreviousRevenue) / PreviousRevenue AS DECIMAL(6, 1)) AS GrowthPct
FROM with_previous
WHERE Month < '2026-11-01' -- the demo data ends on 1 November
ORDER BY Month DESC;
A CTE can also be the target of UPDATE or DELETE, which is a neat way to delete duplicates:
WITH numbered AS (
SELECT ROW_NUMBER() OVER (PARTITION BY Email ORDER BY CustomerId) AS rn
FROM dbo.Customers
)
DELETE FROM numbered WHERE rn > 1;
A CTE is not a temporary table
A CTE is expanded into the query that uses it, like a view. SQL Server doesn’t compute it once and keep the result. Reference it twice and it’s computed twice:
SET STATISTICS IO ON;
WITH spend AS (
SELECT CustomerId, SUM(Total) AS Spent
FROM dbo.Orders
GROUP BY CustomerId
)
SELECT (SELECT MAX(Spent) FROM spend) AS MaxSpent,
(SELECT AVG(Spent) FROM spend) AS AvgSpent;
Table 'Orders'. Scan count 2, logical reads 2680
Two scans of dbo.Orders, 2 × 1,340 pages. Materialize it in a temp table and the aggregation runs once:
SELECT CustomerId, SUM(Total) AS Spent
INTO #spend
FROM dbo.Orders
GROUP BY CustomerId;
SELECT (SELECT MAX(Spent) FROM #spend) AS MaxSpent,
(SELECT AVG(Spent) FROM #spend) AS AvgSpent;
SET STATISTICS IO OFF;
Table 'Orders'. Scan count 1, logical reads 1340
Table '#spend______...'. Scan count 2, logical reads 150
So:
- A CTE is for readability. It is neither faster nor slower than writing the same subquery inline.
- If an expensive CTE is used more than once, or a later step needs an index on its result, use a temp table.
- Recursive CTEs are the exception: their intermediate results are stored in a work table as they’re built.
Recursive CTEs
A recursive CTE has two parts joined by UNION ALL: an anchor query that produces the starting rows, and a recursive query that references the CTE itself to produce the next level. It repeats until the recursive part returns no rows.
A date series
A calendar of every day in a month, which you can outer-join to sales to show days without any:
WITH days AS (
SELECT CAST('2026-10-01' AS date) AS Day
UNION ALL
SELECT DATEADD(DAY, 1, Day) FROM days WHERE Day < '2026-10-31'
)
SELECT d.Day, COUNT(o.OrderId) AS Orders
FROM days AS d
LEFT JOIN dbo.Orders AS o ON o.OrderDate = d.Day
GROUP BY d.Day
ORDER BY d.Day;
On SQL Server 2022, GENERATE_SERIES does the same job without recursion: SELECT DATEADD(DAY, value, '2026-10-01') FROM GENERATE_SERIES(0, 30).
A hierarchy
The classic case is an org chart or a category tree stored as parent-child rows:
CREATE TABLE #Staff (Id INT PRIMARY KEY, Name NVARCHAR(50), ManagerId INT NULL);
INSERT #Staff VALUES (1, N'Sara', NULL), (2, N'Bilal', 1), (3, N'Hina', 1), (4, N'Omar', 2), (5, N'Zara', 4);
WITH chain AS (
SELECT Id, Name, ManagerId, 0 AS Level, CAST(Name AS NVARCHAR(400)) AS Path
FROM #Staff
WHERE ManagerId IS NULL -- anchor: the top
UNION ALL
SELECT s.Id, s.Name, s.ManagerId, c.Level + 1, CAST(c.Path + N' > ' + s.Name AS NVARCHAR(400))
FROM #Staff AS s
JOIN chain AS c ON s.ManagerId = c.Id -- recursive: the next level down
)
SELECT Level, Path FROM chain ORDER BY Path;
Level Path
----- ------------------------
0 Sara
1 Sara > Bilal
2 Sara > Bilal > Omar
3 Sara > Bilal > Omar > Zara
1 Sara > Hina
The CAST in both parts matters: the anchor and recursive columns must have exactly the same type, or you get Types don’t match between the anchor and the recursive part.
MAXRECURSION
By default, recursion stops with an error after 100 levels, which protects you from infinite loops (for example, from a cycle in the data):
WITH n AS (SELECT 1 AS i UNION ALL SELECT i + 1 FROM n WHERE i < 200)
SELECT MAX(i) FROM n;
Msg 530, Level 16, State 1
The statement terminated. The maximum recursion 100 has been exhausted before statement completion.
Raise the limit with a query hint, or set it to 0 for no limit, when you’re sure the recursion ends:
WITH n AS (SELECT 1 AS i UNION ALL SELECT i + 1 FROM n WHERE i < 200)
SELECT MAX(i) AS max_i FROM n
OPTION (MAXRECURSION 500);
For a hierarchy that might contain cycles, keep the path of IDs and stop when the next ID is already in it.
CTE, subquery, view or temp table?
| Use | When |
|---|---|
| CTE | A step in one query that’s clearer with a name; recursion |
| Subquery | A small step used once |
| View | The same query shape reused by many queries |
| Temp table | A result used several times, needing an index, or big enough that the optimizer benefits from its statistics |
In a stored procedure, the difference between a CTE referenced three times and a temp table often shows up as one statement taking far more reads than it should. SpDev Pro shows reads per line, so a CTE that’s computed three times is easy to spot.