Status: Draft (deep-dive v1)
Stack note: Warehouse usp_* boards, pagination helpers, and hierarchy walks (org trees, Agent job steps, recursive path expansion) read cleaner with WITH CTEs — still set-based, still Blazor-callable procs.
Pairs with: Temp vs table vars, APPLY & set-based, Pagination.
Goal: Use non-recursive and recursive CTEs well; know when #temp / @table still win.
| Need | Prefer | Why |
|---|---|---|
| Name a subquery once, reference 2+ times | Non-recursive CTE | Readable; one definition |
| Page a board (keyset / numbered) | CTE + OFFSET/FETCH or keyset (see SQL-25) |
Clear stages: filter → number → slice |
| Walk a hierarchy / graph (parent→child) | Recursive CTE | Built-in; no cursor |
| Large intermediate you index / reuse many times | #temp (SQL-13) |
Stats + indexes; CTE is single-statement scope |
| Tiny in-memory map inside a proc | @table (SQL-13) |
Light; CTE still fine if one statement |
| Per-outer-row table logic | APPLY + iTVF (SQL-17) | Not a CTE job |
WITH Filtered AS (
SELECT a.AlertId, a.SqlInstanceId, a.SeverityCode, a.FirstSeenAt
FROM dbo.Alert a
WHERE a.Status IN (N'Open', N'Acked')
),
Ranked AS (
SELECT f.*,
ROW_NUMBER() OVER (
PARTITION BY f.SqlInstanceId
ORDER BY f.SeverityCode DESC, f.FirstSeenAt DESC
) AS rn
FROM Filtered f
)
SELECT SqlInstanceId, AlertId, SeverityCode, FirstSeenAt
FROM Ranked
WHERE rn <= 5;
Rules of thumb
- A CTE lives for one statement (the SELECT/INSERT/UPDATE/DELETE that follows the WITH list).
- You can chain multiple CTEs separated by commas — later CTEs can read earlier ones.
- Referencing the same CTE twice is OK; the optimizer may spool or inline (don’t assume “runs once”).
WITH Page AS (
SELECT i.SqlInstanceId, i.HostName, i.EnvironmentCode,
ROW_NUMBER() OVER (ORDER BY i.HostName, i.SqlInstanceId) AS rn
FROM dbo.SqlInstance i
WHERE i.IsActive = 1
)
SELECT SqlInstanceId, HostName, EnvironmentCode
FROM Page
WHERE rn BETWEEN @StartRn AND @EndRn
ORDER BY rn;
Prefer keyset pagination for hot Blazor boards (SQL-25); use numbered CTEs for admin/report pages where simplicity beats perfect seek plans.
WITH Walk AS (
-- anchor
SELECT j.JobId, j.ParentJobId, j.JobName, 0 AS Lvl,
CAST(j.JobName AS nvarchar(4000)) AS Path
FROM dbo.AgentJob j
WHERE j.ParentJobId IS NULL
UNION ALL
-- recursive member
SELECT c.JobId, c.ParentJobId, c.JobName, w.Lvl + 1,
CAST(w.Path + N' > ' + c.JobName AS nvarchar(4000))
FROM dbo.AgentJob c
INNER JOIN Walk w ON c.ParentJobId = w.JobId
WHERE w.Lvl < 20 -- guardrail
)
SELECT JobId, Lvl, Path
FROM Walk
ORDER BY Path;
Must-haves
- Anchor + UNION ALL + recursive member that references the CTE name.
- Depth guard (Lvl < N) or a cycle-break column — runaway recursion is a real outage.
- Default MAXRECURSION is 100; override carefully: OPTION (MAXRECURSION 0) only with a hard stop in the query.
Carry a delimited path of IDs and refuse to re-enter:
AND CHARINDEX(N',' + CAST(c.JobId AS nvarchar(20)) + N',', w.IdPath) = 0
#temp vs @table| Concern | CTE | #temp |
@table |
|---|---|---|---|
| Scope | One statement | Proc/session | Batch/proc |
| Stats / indexes | Generally no (inline/spool) | Yes | Weak / limited |
| Multi-step: load → index → join → update | Awkward | Natural | Awkward for large sets |
| Readability of staged logic | Excellent | Good | OK |
| Hierarchy walk | Recursive CTE | Possible but heavier | Rarely |
Practical split for warehouse procs
1. Shape / filter / page in a CTE when it stays one statement.
2. Dump to #temp when you need indexes, multi-step mutation, or reuse across statements (SQL-13).
3. Keep APPLY for per-row TVF work (SQL-17) — don’t force that into recursion.
ROW_NUMBER).#temp.