Chapter 05 · Core Querying: Joins, Subqueries, APPLY, CTEs, and Set Operations

CTEs, Recursive Queries, UNION/INTERSECT/EXCEPT, and Composable Query Design

Compose complex queries with CTEs, guarded recursion, UNION/UNION ALL, INTERSECT and EXCEPT without assuming materialization or unnecessary duplicate elimination.

Intermediate110–140 minutesCTE recursion + set-operator labSQL Server 2025 · compatibility 170Developer/Express · disposable hierarchyLast reviewed: August 2026

Learning outcomes

The final Chapter 05 task combines reusable query stages, a service-zone hierarchy, and comparison of two work-order sets. The team wants CTEs because they “materialize intermediate results,” recursion because it is concise, and UNION everywhere “to be safe.” Those assumptions can create needless duplicate-elimination work or runaway recursion. A common table expression is primarily a named query expression; set operators have explicit duplicate/type semantics; recursion needs a termination model and guardrail.

01

Use nonrecursive CTEs to name query expressions without assuming automatic materialization.

02

Build an anchor/recursive-member hierarchy query and control failure with MAXRECURSION.

03

Choose UNION versus UNION ALL based on duplicate semantics and cost, not habit.

04

Use INTERSECT and EXCEPT for set comparison while accounting for type alignment and NULL equality under set semantics.

05

Read plans for spools/sorts/hashes as observed implementation choices rather than properties guaranteed by CTE syntax.

Lab bootstrap: ensure the set-comparison sources exist

Lesson 5 can run after the earlier lessons or directly after the base ServiceHubLab. The bootstrap creates only missing Chapter 05 tables and leaves existing evidence intact.

sql · ensure visit and tag sets exist
USE ServiceHubLab;GOIF SCHEMA_ID(N'lab05') IS NULL EXEC(N'CREATE SCHEMA lab05 AUTHORIZATION dbo;');GOIF OBJECT_ID(N'lab05.WorkOrderVisit',N'U') IS NULLBEGIN    CREATE TABLE lab05.WorkOrderVisit    (visit_id int IDENTITY(1,1) NOT NULL CONSTRAINT PK_lab05_Visit PRIMARY KEY,     work_order_id bigint NOT NULL, visit_started_at datetime2(0) NOT NULL, outcome varchar(20) NOT NULL);    INSERT lab05.WorkOrderVisit(work_order_id,visit_started_at,outcome) VALUES    (1001,'2026-08-10T10:00:00','inspection'),(1001,'2026-08-10T11:30:00','follow-up'),    (1002,'2026-08-10T11:00:00','replacement'),(1004,'2026-08-11T09:30:00','calibration');END;IF OBJECT_ID(N'lab05.WorkOrderTag',N'U') IS NULLBEGIN    CREATE TABLE lab05.WorkOrderTag    (work_order_id bigint NOT NULL, tag varchar(20) NOT NULL,     CONSTRAINT PK_lab05_Tag PRIMARY KEY(work_order_id,tag));    INSERT lab05.WorkOrderTag(work_order_id,tag) VALUES    (1001,'pump'),(1001,'urgent'),(1001,'north'),(1002,'sensor'),(1004,'meter');END;GO

1. A CTE names a query expression for one statement

A nonrecursive CTE exists for the single statement that follows its WITH clause. Microsoft explicitly notes that CTE query results are not automatically materialized; an outer query that references the CTE can cause the underlying definition to be re-executed. SQL Server may still introduce a spool or another materialization-like physical operator when the optimizer judges it useful, but that is a plan choice, not a CTE guarantee.

sql · compose stages without claiming materialization
USE ServiceHubLab;GO;WITH OpenOrders AS(    SELECT w.work_order_id, w.technician_id, w.priority, w.opened_at    FROM ops.WorkOrder AS w    WHERE w.closed_at IS NULL),Assigned AS(    SELECT o.work_order_id, o.priority, o.opened_at,           t.technician_code, t.region_code    FROM OpenOrders AS o    JOIN ops.Technician AS t      ON t.technician_id = o.technician_id)SELECT *FROM AssignedORDER BY priority DESC, opened_at, work_order_id;GO

The semicolon before WITH safely terminates a previous statement in a batch. The CTEs improve naming and decomposition, but they do not create indexes, statistics, or independent persisted storage. If an intermediate result truly needs reuse with separate indexing/statistics or needs to survive across statements, consider a temporary table and measure the tradeoff.

2. Recursive CTEs iterate from anchor rows until no new rows return

A recursive CTE has one or more anchor members and at least one recursive member. SQL Server runs the anchor to form T0, repeatedly runs the recursive member against the prior result, and stops when the recursive member returns no rows. The last anchor and first recursive member are joined with UNION ALL. Recursive hierarchy columns must align in count and data type.

sql · create a small ServiceHub zone hierarchy
USE ServiceHubLab;GODROP TABLE IF EXISTS lab05.ServiceZone;CREATE TABLE lab05.ServiceZone(    zone_id int NOT NULL CONSTRAINT PK_lab05_ServiceZone PRIMARY KEY,    parent_zone_id int NULL,    zone_name varchar(40) NOT NULL);INSERT lab05.ServiceZone(zone_id,parent_zone_id,zone_name) VALUES(1,NULL,'Global'),(10,1,'North'),(20,1,'West'),(11,10,'North-City'),(12,10,'North-Rural'),(21,20,'West-City');GO;WITH ZoneTree AS(    SELECT z.zone_id, z.parent_zone_id, z.zone_name, 0 AS depth    FROM lab05.ServiceZone AS z    WHERE z.zone_id = 1    UNION ALL    SELECT c.zone_id, c.parent_zone_id, c.zone_name, p.depth + 1    FROM lab05.ServiceZone AS c    JOIN ZoneTree AS p      ON c.parent_zone_id = p.zone_id)SELECT zone_id, parent_zone_id, zone_name, depthFROM ZoneTreeORDER BY depth, zone_idOPTION (MAXRECURSION 20);GO

MAXRECURSION is a query hint that limits recursion depth (0 means no limit; positive values are bounded). It is a guardrail, not a substitute for a correct termination predicate or acyclic data model. Choose a bound from the expected domain depth and operational safety requirement rather than copying 100 or 0 blindly.

3. Deliberately create a cycle and observe the guardrail

Real hierarchies can become cyclic through bad imports or missing constraints. If Global points under one of its descendants, a traversal that expects a tree can loop. This disposable lab injects a cycle and uses a low MAXRECURSION so the statement terminates with an error rather than consuming unbounded work.

sql · cycle failure and repair
USE ServiceHubLab;GOUPDATE lab05.ServiceZoneSET parent_zone_id = 11WHERE zone_id = 1;GO;WITH ZoneTree AS(    SELECT zone_id, parent_zone_id, zone_name, 0 AS depth    FROM lab05.ServiceZone    WHERE zone_id = 1    UNION ALL    SELECT c.zone_id, c.parent_zone_id, c.zone_name, p.depth + 1    FROM lab05.ServiceZone AS c    JOIN ZoneTree AS p ON c.parent_zone_id = p.zone_id)SELECT * FROM ZoneTreeOPTION (MAXRECURSION 5);GO-- Repair the disposable hierarchy.UPDATE lab05.ServiceZoneSET parent_zone_id = NULLWHERE zone_id = 1;GO

The expected failure is a recursion-limit error. That proves the guardrail fired; it does not identify the business cause automatically. Validate hierarchy invariants during ingestion or with controlled cycle-detection logic when the domain requires a tree/DAG.

4. UNION ALL concatenates; UNION also removes duplicates

UNION ALL combines compatible result sets and preserves duplicates. UNION applies distinct set semantics to the combined rows, which usually requires additional work such as sorting or hashing. Use UNION when duplicate elimination is part of the result definition, not as defensive formatting. Corresponding columns must be type-compatible; resulting names come from the first query.

sql · make duplicate semantics visible
USE ServiceHubLab;GOSELECT customer_codeFROM ops.WorkOrderWHERE status IN ('new','assigned')UNION ALLSELECT customer_codeFROM ops.WorkOrderWHERE priority >= 2;GOSELECT customer_codeFROM ops.WorkOrderWHERE status IN ('new','assigned')UNIONSELECT customer_codeFROM ops.WorkOrderWHERE priority >= 2;GO

A customer satisfying both branches can appear twice with UNION ALL and once with UNION. Neither is universally “better”; one preserves bag/multiset multiplicity while the other asks for a mathematical-set-like result. Inspect plans to see the actual duplicate-elimination strategy chosen.

5. INTERSECT and EXCEPT express overlap and difference

INTERSECT returns distinct rows present in both inputs. EXCEPT returns distinct rows from the left input that are absent from the right input. For these set operators, NULL values compare as equal for duplicate/set determination, which differs from ordinary = NULL three-valued logic. Column count and compatible types must align, and data-type precedence/collation rules can affect the result contract.

sql · compare work-order sets directly
USE ServiceHubLab;GO-- Work orders that both have a visit and have a tag.SELECT work_order_id FROM lab05.WorkOrderVisitINTERSECTSELECT work_order_id FROM lab05.WorkOrderTag;GO-- Work orders with visits but no tags.SELECT work_order_id FROM lab05.WorkOrderVisitEXCEPTSELECT work_order_id FROM lab05.WorkOrderTag;GO

These formulations are often clearer than a manual join when the business question is literally set overlap/difference. They return distinct rows. If multiplicity or child detail matters, a join or EXISTS pattern may represent the requirement better.

6. Plan evidence: a spool is observed, not promised

sql · capture CTE/set-operator evidence locally
USE ServiceHubLab;GOSET STATISTICS IO ON;;WITH VisitCounts AS(    SELECT work_order_id, COUNT(*) AS visit_count    FROM lab05.WorkOrderVisit    GROUP BY work_order_id)SELECT a.work_order_id, a.visit_count, b.visit_count AS repeated_referenceFROM VisitCounts AS aJOIN VisitCounts AS b ON b.work_order_id = a.work_order_idORDER BY a.work_order_id;SET STATISTICS IO OFF;GO

Capture the actual plan and look for scans, aggregates, joins, and any spool. If a spool appears, it is evidence that this optimizer chose to cache/intermediate some rows for this plan. If it does not, that does not make the CTE invalid. When repeated expensive references matter, compare a temp-table alternative with measured reads/CPU and realistic scale.

7. Chapter lab cleanup, production judgment, and bridge

Wrong approach

“CTEs materialize, UNION is safer than UNION ALL, and MAXRECURSION 0 avoids errors.” All three claims replace explicit semantics with folklore. They can cause repeated work, unnecessary duplicate elimination, or runaway recursion.

Repair

Choose CTEs for statement-scoped composability, set operators for explicit duplicate/overlap/difference semantics, and recursion with a proven termination model plus a defensible guardrail. Inspect plans to learn the physical implementation rather than assigning one by syntax.

sql · clean up disposable Chapter 05 objects
USE ServiceHubLab;GODROP FUNCTION IF EXISTS lab05.VisitSummary_MSTVF;DROP FUNCTION IF EXISTS lab05.VisitForWorkOrder;DROP TABLE IF EXISTS lab05.ServiceZone;DROP TABLE IF EXISTS lab05.BlockedCustomer;DROP TABLE IF EXISTS lab05.WorkOrderTag;DROP TABLE IF EXISTS lab05.WorkOrderVisit;GOIF SCHEMA_ID(N'lab05') IS NOT NULL   AND NOT EXISTS (SELECT 1 FROM sys.objects WHERE schema_id = SCHEMA_ID(N'lab05'))    EXEC(N'DROP SCHEMA lab05;');GO

Chapter 05 finishes the core query-composition layer: ordering and row limiting define deterministic presentation; joins define row combinations; EXISTS/IN/subqueries define existence and scalar contracts; APPLY handles lateral table expressions; CTEs and set operators compose larger queries. Chapter 06 builds on this foundation with window functions, advanced aggregation, PIVOT, safe data-change patterns, and JSON workloads.

Production prerequisites remain SQL Server 2025 CU7 build 17.0.4065.4, compatibility level 170 for the declared lab baseline, Developer/Express, and permissions to create/drop the disposable lab objects. No paid edition, cloud service, restart, or retired Azure Data Studio dependency is required.

Check your understanding

  1. Does a CTE guarantee that its result is materialized once?
  2. What are the two conceptual parts of a recursive CTE?
  3. Why is MAXRECURSION 0 risky on untrusted hierarchy data?
  4. When should UNION be preferred over UNION ALL?
  5. What does EXCEPT return?
Review the answers

No. It is a named query expression; the optimizer may or may not introduce a physical spool/materialization strategy.

Anchor member(s) that seed the result and recursive member(s) that repeatedly consume the previous iteration.

It removes the recursion limit, so a cycle or faulty termination condition can run until another resource/error intervenes.

When duplicate elimination is part of the required result semantics.

Distinct rows from the left input that are not present in the right input.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.