Back to Article List

Common table expressions in SQL Server: WITH and recursive

Common table expressions in SQL Server: WITH and recursive

Updated and tested 12 October 2026

A common table expression names a query so the statement underneath can treat it like a table. Search traffic calls it an MSSQL CTE and the documentation calls it a CTE. Same thing. Three levels of nested subquery turn into a list of named steps that read top to bottom, which is most of why I write them. What the name does not buy you is a stored result. SQL Server does not compute the query once and hold on to it, and the reference page says so in plain English, in a sentence I quote in full further down.

Everything below runs against four tables I reuse across this batch: dbo.customers, dbo.orders, dbo.order_items and dbo.employees. Standard orders schema. The employee table carries a self-referencing manager_id, which the hierarchy examples need.

WITH clause syntax and the column alias form

The WITH clause in SQL Server takes a name, the keyword AS, a parenthesised query and then the single statement that consumes it.

WITH recent_orders AS
(
    SELECT order_id, customer_id, order_date, total_amount
    FROM dbo.orders
    WHERE order_date >= '2026-01-01'
)
SELECT c.company_name,
       COUNT(*)             AS order_count,
       SUM(r.total_amount)  AS revenue
FROM recent_orders AS r
JOIN dbo.customers AS c ON c.customer_id = r.customer_id
GROUP BY c.company_name
ORDER BY revenue DESC;

Filtering inside a CTE is just filtering, and WHERE clause behaviour in SQL Server is identical on both sides of the closing bracket. Scope is what differs. The consuming statement has to follow immediately, with no DECLARE between them, no SET and no second query. The name exists for one statement and is then gone, so a CTE never needs cleaning up.

The CTE can declare its own output column names, which saves aliasing them inside:

WITH recent_orders (order_id, customer_id, placed_on, amount) AS
(
    SELECT order_id, customer_id, order_date, total_amount
    FROM dbo.orders
    WHERE order_date >= '2026-01-01'
)
SELECT placed_on, amount
FROM recent_orders
WHERE amount > 500;

Name every column or none of them, and match the count exactly, or you get an error about the number of columns not matching. I skip this form on simple CTEs and use it on recursive ones, where naming the columns once at the top saves aliasing the same expression in two separate branches.

The semicolon before WITH and the ;WITH idiom

Microsoft's wording on the WITH common table expression reference is short: "When a CTE is used in a statement that is part of a batch, the statement before it must be followed by a semicolon."

DECLARE @cutoff date = '2026-01-01';

WITH recent_orders AS
(
    SELECT order_id, order_date
    FROM dbo.orders
    WHERE order_date >= @cutoff
)
SELECT COUNT(*) AS orders_since_cutoff
FROM recent_orders;

Drop the semicolon after that DECLARE and the parser hands you a message that explains itself unusually well for T-SQL:

Incorrect syntax near the keyword 'with'. If this statement is a common
table expression, an xmlnamespaces clause or a change tracking context
clause, the previous statement must be terminated with a semicolon.

WITH is badly overloaded in T-SQL. It introduces table hints in FROM dbo.orders WITH (NOLOCK), options in CREATE VIEW ... WITH SCHEMABINDING, result set definitions in EXECUTE ... WITH RESULT SETS and a handful of others. With no terminator on the statement above, the parser cannot tell which one it is looking at. That error text is also one of the very few in T-SQL that names the fix. The rest hand you a line number and the word "near". I have always assumed somebody on the parser team got tired of the same support ticket. Anyway.

That is where ;WITH comes from. Somebody got bitten once, started writing the semicolon on the front of the CTE where it cannot be forgotten, and the habit spread through every code base I have inherited. It works. I still do not write it, because a leading semicolon on line one is a workaround for not terminating the line above, and terminating the line above is free. Snippets are the exception. Generated code, and anything a support engineer will paste into an unknown batch in a hurry, gets the leading semicolon, since there the statement above yours is not yours to fix.

Common table expression results are not materialized

This is the sentence that changes how you write queries, straight from the same reference page: "Query results from common table expressions aren't materialized. Each outer reference to the named result set requires the defined query to be re-executed. For queries that require multiple references to the named result set, consider using a temporary object instead."

Read it again with a plan in mind. A CTE is closer to a macro than a table. The optimiser inlines the definition at every point of reference and then optimises the whole expanded tree. Reference it three times and the work inside runs three times.

WITH monthly AS
(
    SELECT DATEFROMPARTS(YEAR(order_date), MONTH(order_date), 1) AS month_start,
           SUM(total_amount) AS revenue
    FROM dbo.orders
    WHERE status = 'shipped'
    GROUP BY DATEFROMPARTS(YEAR(order_date), MONTH(order_date), 1)
)
SELECT curr.month_start,
       curr.revenue,
       prev.revenue AS prev_month,
       yr.revenue   AS same_month_last_year
FROM monthly AS curr
LEFT JOIN monthly AS prev ON prev.month_start = DATEADD(month, -1, curr.month_start)
LEFT JOIN monthly AS yr   ON yr.month_start   = DATEADD(year,  -1, curr.month_start)
ORDER BY curr.month_start;

Turn on the actual execution plan and count the operators. Three scans of dbo.orders, three hash aggregates, three of everything. The query reads like one aggregation feeding three uses. It executes as three aggregations. On a table with a few thousand rows nobody notices. On an orders table with eighty million rows and a filtered index that still has to be scanned, you have tripled the slowest thing in the report.

Rewriting the multi-reference CTE as a temp table

SELECT DATEFROMPARTS(YEAR(order_date), MONTH(order_date), 1) AS month_start,
       SUM(total_amount) AS revenue
INTO #monthly
FROM dbo.orders
WHERE status = 'shipped'
GROUP BY DATEFROMPARTS(YEAR(order_date), MONTH(order_date), 1);

CREATE CLUSTERED INDEX ix_monthly_month ON #monthly (month_start);

SELECT curr.month_start,
       curr.revenue,
       prev.revenue AS prev_month,
       yr.revenue   AS same_month_last_year
FROM #monthly AS curr
LEFT JOIN #monthly AS prev ON prev.month_start = DATEADD(month, -1, curr.month_start)
LEFT JOIN #monthly AS yr   ON yr.month_start   = DATEADD(year,  -1, curr.month_start)
ORDER BY curr.month_start;

DROP TABLE #monthly;

One aggregation. Twelve or twenty-four rows land in tempdb, the clustered index makes the self joins trivial, and the optimiser now has real statistics on the intermediate set. Before that it was working from an estimate derived from a guess. That clustered index is doing more work than people expect, and on a result set this small it costs nothing.

I switch when two conditions hold at once: the CTE is referenced more than once and its body does real work. Real work means an aggregation, a scan of a large table, a join across several tables or a scalar function call per row. A CTE referenced twice that filters a 400-row lookup table by one column can stay a CTE forever. Two references to a nine-table join that reads a quarter of your fact table goes into tempdb the same afternoon you find it.

Your idea deserves better hosting

24/7 support 30-day money-back guaranteeRefund covers the VPS plan on your first order. Cancel anytime
Строк Оплати

VPS.S1

$5.99 Save  17 %
$4.99 Щомісячно
  • 2 vCPU AMD EPYC
  • 2 GB RAMПАМʼЯТЬ
  • 30 GB NVMeСХОВИЩЕ
  • Безлімітний трафік
  • IPv4 & IPv6Підтримка IPv6 наразі недоступна у Франції, Фінляндії чи Нідерландах. включено

VPS.S3

$14.99 Save  33 %
$9.99 Щомісячно
  • 4 vCPU AMD EPYC
  • 6 GB RAMПАМʼЯТЬ
  • 70 GB NVMeСХОВИЩЕ
  • Безлімітний трафік
  • IPv4 & IPv6Підтримка IPv6 наразі недоступна у Франції, Фінляндії чи Нідерландах. включено

VPS.S4

$19.99 Save  20 %
$15.99 Щомісячно
  • 6 vCPU AMD EPYC
  • 8 GB RAMПАМʼЯТЬ
  • 80 GB NVMeСХОВИЩЕ
  • Безлімітний трафік
  • IPv4 & IPv6Підтримка IPv6 наразі недоступна у Франції, Фінляндії чи Нідерландах. включено

EPYC VPS.P1

$8.99 Save  22 %
$6.99 Щомісячно
  • 2 vCPU AMD EPYC
  • 4 GB RAMПАМʼЯТЬ
  • 40 GB NVMeСХОВИЩЕ
  • Безлімітний трафік
  • IPv4 & IPv6Підтримка IPv6 наразі недоступна у Франції, Фінляндії чи Нідерландах. включено
  • Безкоштовний авто бекапМістить один слот резервного копіювання, який можна налаштувати на щоденний, щотижневий або щомісячний запуск.

EPYC VPS.P2

$16.99 Save  24 %
$12.99 Щомісячно
  • 2 vCPU AMD EPYC
  • 8 GB RAMПАМʼЯТЬ
  • 80 GB NVMeСХОВИЩЕ
  • Безлімітний трафік
  • IPv4 & IPv6Підтримка IPv6 наразі недоступна у Франції, Фінляндії чи Нідерландах. включено
  • Безкоштовний авто бекапМістить один слот резервного копіювання, який можна налаштувати на щоденний, щотижневий або щомісячний запуск.

EPYC VPS.P4

$29.99 Save  23 %
$22.99 Щомісячно
  • 4 vCPU AMD EPYC
  • 16 GB RAMПАМʼЯТЬ
  • 160 GB NVMeСХОВИЩЕ
  • Безлімітний трафік
  • IPv4 & IPv6Підтримка IPv6 наразі недоступна у Франції, Фінляндії чи Нідерландах. включено
  • Безкоштовний авто бекапМістить один слот резервного копіювання, який можна налаштувати на щоденний, щотижневий або щомісячний запуск.

EPYC VPS.P5

$39.99 Save  25 %
$29.99 Щомісячно
  • 8 vCPU AMD EPYC
  • 16 GB RAMПАМʼЯТЬ
  • 180 GB NVMeСХОВИЩЕ
  • Безлімітний трафік
  • IPv4 & IPv6Підтримка IPv6 наразі недоступна у Франції, Фінляндії чи Нідерландах. включено
  • Безкоштовний авто бекапМістить один слот резервного копіювання, який можна налаштувати на щоденний, щотижневий або щомісячний запуск.

EPYC VPS.P6

$59.99 Save  25 %
$44.99 Щомісячно
  • 8 vCPU AMD EPYC
  • 32 GB RAMПАМʼЯТЬ
  • 200 GB NVMeСХОВИЩЕ
  • Безлімітний трафік
  • IPv4 & IPv6Підтримка IPv6 наразі недоступна у Франції, Фінляндії чи Нідерландах. включено
  • Безкоштовний авто бекапМістить один слот резервного копіювання, який можна налаштувати на щоденний, щотижневий або щомісячний запуск.

EPYC VPS.P7

$69.99 Save  29 %
$49.99 Щомісячно
  • 16 vCPU AMD EPYC
  • 32 GB RAMПАМʼЯТЬ
  • 240 GB NVMeСХОВИЩЕ
  • Безлімітний трафік
  • IPv4 & IPv6Підтримка IPv6 наразі недоступна у Франції, Фінляндії чи Нідерландах. включено
  • Безкоштовний авто бекапМістить один слот резервного копіювання, який можна налаштувати на щоденний, щотижневий або щомісячний запуск.

Genoa VPS.G2

$24.99 Save  20 %
$19.99 Щомісячно
  • 2 vCPUAMD EPYC Genoa 4-го покоління 9xx4 з 3,25 GHz або подібний, на архітектурі Zen 4. AMD EPYC G4
  • 4 GB DDR5ПАМʼЯТЬ
  • 50 GB NVMeСХОВИЩЕ
  • Безлімітний трафік
  • IPv4 & IPv6Підтримка IPv6 наразі недоступна у Франції, Фінляндії чи Нідерландах. включено
  • Безкоштовний авто бекапМістить один слот резервного копіювання, який можна налаштувати на щоденний, щотижневий або щомісячний запуск.

Genoa VPS.G4

$44.99 Save  22 %
$34.99 Щомісячно
  • 4 vCPUПроцесор AMD EPYC з виділеними ядрами vCPU, на серверному обладнанні корпоративного класу. AMD EPYC G4
  • 8 GB DDR5ПАМʼЯТЬ
  • 100 GB NVMeСХОВИЩЕ
  • Безлімітний трафік
  • IPv4 & IPv6Підтримка IPv6 наразі недоступна у Франції, Фінляндії чи Нідерландах. включено
  • Безкоштовний авто бекапМістить один слот резервного копіювання, який можна налаштувати на щоденний, щотижневий або щомісячний запуск.

Genoa VPS.G6

$89.99 Save  22 %
$69.99 Щомісячно
  • 8 vCPUПроцесор AMD EPYC з виділеними ядрами vCPU, на серверному обладнанні корпоративного класу. AMD EPYC G4
  • 16 GB DDR5ПАМʼЯТЬ
  • 200 GB NVMeСХОВИЩЕ
  • Безлімітний трафік
  • IPv4 & IPv6Підтримка IPv6 наразі недоступна у Франції, Фінляндії чи Нідерландах. включено
  • Безкоштовний авто бекапМістить один слот резервного копіювання, який можна налаштувати на щоденний, щотижневий або щомісячний запуск.

Genoa VPS.G7

$159.99 Save  22 %
$124.99 Щомісячно
  • 8 vCPUПроцесор AMD EPYC з виділеними ядрами vCPU, на серверному обладнанні корпоративного класу. AMD EPYC G4
  • 32 GB DDR5ПАМʼЯТЬ
  • 250 GB NVMeСХОВИЩЕ
  • Безлімітний трафік
  • IPv4 & IPv6Підтримка IPv6 наразі недоступна у Франції, Фінляндії чи Нідерландах. включено
  • Безкоштовний авто бекапМістить один слот резервного копіювання, який можна налаштувати на щоденний, щотижневий або щомісячний запуск.

AMD Ryzen VPS.R1

$16.99 Save  18 %
$13.99 Щомісячно
  • 1 виділений CPU AMD Ryzen 9 7950X з 4,5 GHz або подібний, на архітектурі Zen 4. vCPU
  • 4 GB DDR5ПАМ'ЯТЬ
  • 50 GB NVMeСХОВИЩЕ
  • Безлімітний трафік
  • IPv4 & IPv6 включено Підтримка IPv6 наразі недоступна у Франції, Фінляндії чи Нідерландах.
  • Авто бекап включено

AMD Ryzen VPS.R2

$29.99 Save  17 %
$24.99 Щомісячно
  • 2 виділених CPU AMD Ryzen 9 7950X з 4,5 GHz або подібний, на архітектурі Zen 4. vCPU
  • 8 GB DDR5ПАМ'ЯТЬ
  • 100 GB NVMeСХОВИЩЕ
  • Безлімітний трафік
  • IPv4 & IPv6 включено Підтримка IPv6 наразі недоступна у Франції, Фінляндії чи Нідерландах.
  • Авто бекап включено

AMD Ryzen VPS.R4

$109.99 Save  18 %
$89.99 Щомісячно
  • 8 виділених CPU AMD Ryzen 9 7950X з 4,5 GHz або подібний, на архітектурі Zen 4. vCPU
  • 32 GB DDR5ПАМ'ЯТЬ
  • 400 GB NVMeСХОВИЩЕ
  • Безлімітний трафік
  • IPv4 & IPv6 включено Підтримка IPv6 наразі недоступна у Франції, Фінляндії чи Нідерландах.
  • Авто бекап включено

*VAT excluded.

INCLUDED WITH EVERY PLAN

✓ No setup fees ✓1 Gbps network
✓ Full root access on KVM ✓ Free server monitoring ✓ Firewall management ✓24/7 support

Multiple CTEs in one WITH

One WITH, comma-separated names, no repeat of the keyword. Each CTE can reference any CTE defined above it.

WITH shipped AS
(
    SELECT order_id, customer_id, total_amount
    FROM dbo.orders
    WHERE status = 'shipped'
),
customer_totals AS
(
    SELECT customer_id,
           SUM(total_amount) AS lifetime_value,
           COUNT(*)          AS orders_placed
    FROM shipped
    GROUP BY customer_id
),
ranked AS
(
    SELECT customer_id, lifetime_value, orders_placed,
           ROW_NUMBER() OVER (ORDER BY lifetime_value DESC) AS rn
    FROM customer_totals
)
SELECT c.company_name, r.lifetime_value, r.orders_placed
FROM ranked AS r
JOIN dbo.customers AS c ON c.customer_id = r.customer_id
WHERE r.rn <= 20
ORDER BY r.rn;

Each step is named and readable on its own. You can comment out the final SELECT, put SELECT * FROM customer_totals in its place and inspect any stage of the pipeline. Forward references do not work, so shipped cannot see ranked. The re-execution rule applies to every link in the chain, which is how a five-CTE pipeline with one twice-referenced step produces a plan that looks nothing like the query you wrote.

UPDATE and DELETE with a CTE

A CTE is not read-only. Target the CTE name in the UPDATE or DELETE and SQL Server pushes the change through to the base table.

WITH stale AS
(
    SELECT order_id, status
    FROM dbo.orders
    WHERE status = 'pending'
      AND order_date < DATEADD(day, -30, SYSUTCDATETIME())
)
UPDATE stale
SET status = 'expired';

The CTE has to be updatable for this, which means one base table, no GROUP BY, no DISTINCT, no aggregate in the select list and no UNION. Join to other tables inside the CTE if you need to, as long as the SET only touches columns from one of them.

There is more on this ground in writing UPDATE statements in SQL Server. The non-determinism warning applies here without any change: a target row matching more than one source row gets one of them, chosen arbitrarily, and nothing is raised. That warning comes with any UPDATE carrying a FROM clause. So run every CTE-driven update as a SELECT first, look at the count, then swap the verb.

Delete works the same way:

WITH orphans AS
(
    SELECT i.order_item_id
    FROM dbo.order_items AS i
    LEFT JOIN dbo.orders AS o ON o.order_id = i.order_id
    WHERE o.order_id IS NULL
)
DELETE FROM orphans;

Recursive CTEs in SQL Server and an employee hierarchy

A recursive CTE has two halves joined by UNION ALL. The anchor member runs once and produces the starting rows. The recursive member references the CTE by name and runs repeatedly, each pass feeding on the rows the previous pass produced, until a pass returns nothing.

dbo.employees holds employee_id, full_name, job_title and a nullable manager_id pointing at another row in the same table. The people with a NULL manager sit at the top of the tree.

Anchor member and recursive member

WITH org_chart AS
(
    -- anchor: everyone with no manager
    SELECT e.employee_id,
           e.full_name,
           e.manager_id,
           0 AS depth,
           CAST(e.full_name AS nvarchar(4000)) AS path
    FROM dbo.employees AS e
    WHERE e.manager_id IS NULL

    UNION ALL

    -- recursive: everyone who reports to a row we already have
    SELECT e.employee_id,
           e.full_name,
           e.manager_id,
           oc.depth + 1,
           CAST(oc.path + N' > ' + e.full_name AS nvarchar(4000))
    FROM dbo.employees AS e
    INNER JOIN org_chart AS oc ON e.manager_id = oc.employee_id
)
SELECT employee_id,
       REPLICATE(N'    ', depth) + full_name AS indented_name,
       depth,
       path
FROM org_chart
ORDER BY path;

Three details in there matter. The depth counter starts at 0 in the anchor and increments in the recursive member, which gives you the indentation and a cheap way to cap the tree. The path column concatenates names as it descends, so sorting by it produces the tree in the order a human draws one, parents above their reports. Both branches wrap the path in an explicit CAST to nvarchar(4000), which is not optional.

Type precedence and the CAST rules are what decide that third one. Leave the cast out and you get error 240: "Types don't match between the anchor and the recursive part in column path of recursive query org_chart." The anchor's full_name is nvarchar(100), the recursive member's concatenation produces something wider, and the column definitions have to agree exactly across both branches in type, length, precision and scale. Nail the width down on both sides and it goes away.

MAXRECURSION and the 100 iteration default

SQL Server caps recursion at 100 passes by default and kills the statement when it is reached:

The statement terminated. The maximum recursion 100 has been exhausted
before statement completion.

That default has saved more servers than it has annoyed people. A typo in the join predicate on a recursive member gives you an infinite loop that writes to tempdb until the drive fills, and 100 iterations is a cheap circuit breaker. Raise it per statement with a query hint on the outer query:

OPTION (MAXRECURSION 500);

Valid values run from 0 to 32767. Zero means no limit at all, which is what people reach for the moment they hit the ceiling. Set an explicit number instead, one comfortably above the deepest tree you expect. An org chart 14 levels deep gets MAXRECURSION 50. If that ever fires, something is wrong with the data, probably a manager cycle where two people report to each other, and an error beats a query that runs for forty minutes.

The hint attaches to the statement and not to the CTE, and only one OPTION clause is allowed per statement, so combine hints inside the same brackets.

Generating a date series with a recursive CTE

The other recursive pattern worth having in reach builds calendars. Reports need a row for every day in a range including the days where nothing happened, and a recursive CTE produces that calendar without a numbers table:

WITH date_series AS
(
    SELECT CAST('2026-01-01' AS date) AS calendar_date

    UNION ALL

    SELECT DATEADD(day, 1, calendar_date)
    FROM date_series
    WHERE calendar_date < '2026-12-31'
)
SELECT ds.calendar_date,
       COUNT(o.order_id)          AS orders_placed,
       ISNULL(SUM(o.total_amount), 0) AS revenue
FROM date_series AS ds
LEFT JOIN dbo.orders AS o
       ON CAST(o.order_date AS date) = ds.calendar_date
GROUP BY ds.calendar_date
ORDER BY ds.calendar_date
OPTION (MAXRECURSION 400);

The WHERE inside the recursive member is the terminator: once calendar_date reaches 31 December the pass returns zero rows and recursion stops. 2026 has 365 days, so anything under MAXRECURSION 364 fails, which is exactly the sort of off-by-one that turns into a support ticket in a leap year.

That LEFT JOIN sits in the outer query, where joins of any kind are allowed. Casting o.order_date to date on the left of the comparison is a readability choice here; SQL Server date functions makes the case for a half-open range against the raw column instead, which is what you want on a large orders table, because the index survives it. Same rows, very different plan.

Microsoft's date and time functions hub lists what each of them returns.

Recursive CTE output columns are all nullable

Every column coming out of a recursive CTE is nullable, no matter what the base table says. employee_id is the NOT NULL primary key of dbo.employees, and the moment it passes through org_chart the metadata says it accepts NULLs.

Nobody cares until they write SELECT ... INTO dbo.org_snapshot FROM org_chart, find every column in the new table nullable and then cannot add a primary key without altering the columns first. ISNULL and COALESCE disagree about nullability, and that disagreement is the fix: wrap the column in ISNULL(employee_id, -1) and the result is treated as NOT NULL. COALESCE hands back a nullable column and no primary key.

What a recursive member cannot contain

The recursive half of the CTE runs under real restrictions. These are rejected outright:

  • SELECT DISTINCT
  • GROUP BY and HAVING
  • PIVOT
  • scalar aggregation
  • TOP
  • LEFT JOIN, RIGHT JOIN and OUTER JOIN against the recursive reference (INNER JOIN is fine)
  • subqueries
  • hints on the recursive reference

The recursive member's FROM may name the CTE exactly once. Twice is an error, which rules out the obvious trick for walking a tree in both directions in one pass. Anything you would have done with GROUP BY or TOP inside the recursion goes in the outer query instead, after the whole set is built. That is usually fine and occasionally wasteful, and there is no way around it.

Anchor members are treated more loosely. Several of them can be combined with UNION, EXCEPT or INTERSECT as well as UNION ALL. Every recursive CTE I have shipped uses a single anchor, so I put the multi-anchor question to a colleague who writes far more of this than I do. His answer was that he keeps EXCEPT out of that position because the plans stop being readable. That is his experience talking. I have not reproduced it.

Deduplicating rows with ROW_NUMBER in a CTE

Rank the rows inside each group of duplicates, give the ranking a name, delete everything ranked above 1.

WITH dupes AS
(
    SELECT order_item_id,
           order_id,
           product_sku,
           ROW_NUMBER() OVER (PARTITION BY order_id, product_sku
                              ORDER BY order_item_id) AS rn
    FROM dbo.order_items
)
SELECT *
FROM dupes
WHERE rn > 1;

Run that SELECT first. Every time. When the row count looks right, swap the last two lines:

WITH dupes AS
(
    SELECT order_item_id,
           ROW_NUMBER() OVER (PARTITION BY order_id, product_sku
                              ORDER BY order_item_id) AS rn
    FROM dbo.order_items
)
DELETE FROM dupes
WHERE rn > 1;

The PARTITION BY list defines what "duplicate" means. The ORDER BY decides which copy survives: order_item_id keeps the oldest, ORDER BY order_item_id DESC keeps the newest. Neither is a default, so state it.

On a table anyone cares about, wrap the delete in an explicit transaction so you can roll back after checking @@ROWCOUNT. If the table is large, delete in batches with DELETE TOP (4000) FROM dupes WHERE rn > 1; in a loop. One pass takes an enormous lock and leaves you a log file the size of the table.

CTE, derived table, temp table or table variable

Four ways to name an intermediate result, and the choice is less about taste than people pretend.

A derived table, meaning a subquery in the FROM clause, fits when the intermediate set is used exactly once and the query stays readable. There is no performance difference between a derived table and a CTE referenced once. They compile to the same thing. Pick whichever reads better, and past two levels of nesting the CTE reads better every time.

A CTE is for readability, for chaining named steps and for recursion, which is the one job nothing else here can do. Single reference, or multiple references to something cheap. It carries no statistics of its own, cannot be indexed and cannot be reused by the next statement in the batch.

A temp table takes everything else. Multiple references to expensive logic, an intermediate set you want to index, a result you need across several statements or a set the optimiser keeps mis-estimating. Temp tables get full statistics, they can carry a clustered index, and they let you break a monstrous query into stages you profile one at a time. The cost is tempdb traffic and recompiles.

A table variable is the narrowest of the four. Small sets, passing rows into a stored procedure as a table-valued parameter, and the case where the data has to survive a rollback, since a rollback does not touch a table variable. Modern versions do deferred compilation for table variables, which fixed the worst of the old one-row estimate problem. I still default to #temp above a few hundred rows.

One option people forget: a correlated CROSS APPLY often replaces a CTE plus a self join outright, particularly for top-N-per-group work. I solved this same problem both ways in CROSS APPLY and OUTER APPLY, with a plan for each. Compare them before assuming the CTE version is the cleaner one. The answer depends on your indexes more than on the syntax.

The habit to build is small. After writing any CTE, count how many times the name appears below the closing bracket. One reference costs nothing extra. More than one, open the plan before you defend the query.

Questions?

Can I use a CTE inside a view?

Yes, Microsoft's WITH reference allows a CTE as part of the defining SELECT of a CREATE VIEW, so the view body can start with WITH recent_orders AS (...) SELECT .... Inside a stored procedure a CTE works the same as in an ad hoc batch. The view does not change how the CTE runs: a CTE referenced twice in the view's query is still expanded twice every time someone selects from the view.

Jörg L. Günther
About the author

Jörg L. Günther

Jörg has spent much of his career in enterprise software, working first with database deployments and customer environments, then in technical training and documentation. Since then he has written for companies working in virtualisation, database tooling and cloud infrastructure. The subjects he takes on often have a large operational component: database migrations, high-availability setups, storage configuration and procedures carried out on live systems. Jörg lives near Frankfurt. He plays piano and cycles for transport as much as for recreation. He's also been doing woodworking for several years - he says most of the furniture in his study is his own work.

Deploy your next project in seconds

Bring your winning ideas online faster, with modern hardware and unmetered bandwidth. Join a European cloud trusted by thousands of developers and businesses worldwide.