Chapter 05 · SQL Querying: Joins, Subqueries, CTEs, Set Operations, and LATERAL

CTEs, Recursive Queries, Materialization Control, and Graph/Hierarchy Traversal

Use PostgreSQL common table expressions for readable query layers and recursive graph traversal, reason about termination and cycles, and apply MATERIALIZED/NOT MATERIALIZED as planner controls rather than folklore.

Intermediate135–165 minutesCTE + recursive hierarchy labCurrent patched PostgreSQL 18.xCore SQL; no extensions requiredLast reviewed: August 2026

Learning outcomes

ServiceHub now needs an organizational hierarchy report and a query pipeline with several readable stages. Common table expressions (CTEs) solve both problems, but two myths create trouble: “a CTE is always an optimization fence” and “WITH RECURSIVE automatically prevents cycles.” PostgreSQL 18 requires a more precise model.

01

Use nonrecursive CTEs as named relational steps without assuming they are always materialized.

02

Explain PostgreSQL folding rules for side-effect-free CTEs and use MATERIALIZED/NOT MATERIALIZED only when the tradeoff is understood.

03

Build recursive CTEs from an anchor term, recursive term, and termination rule.

04

Prevent graph cycles using explicit path logic or PostgreSQL CYCLE syntax.

05

Inspect plan evidence only after preserving the intended relational result.

1. Chapter 05 lab bootstrap

All five lessons use the same small ServiceHub dataset so row counts and edge cases remain explainable. Run this as servicehub_owner (or another role that owns the disposable app schema) in servicehub_lab. The objects are deliberately prefixed ch05_. Before the drops, verify the current database and inspect the matching objects; do not paste this into a production database.

Safety boundary

The DROP statements below target only chapter-scoped objects. If your database contains valuable objects with these names, stop and use another disposable database. No CASCADE is used.

sql · reset and seed the deterministic ServiceHub query lab
\echo 'Verify target before recreating chapter-scoped lab objects'SELECT current_database(), current_user;\dt app.ch05_*DROP TABLE IF EXISTS app.ch05_work_order_event;DROP TABLE IF EXISTS app.ch05_work_order_tag;DROP TABLE IF EXISTS app.ch05_work_order;DROP TABLE IF EXISTS app.ch05_technician;DROP TABLE IF EXISTS app.ch05_customer;DROP TABLE IF EXISTS app.ch05_team;CREATE TABLE app.ch05_customer (    customer_id integer PRIMARY KEY,    customer_name text NOT NULL,    region text,    active boolean NOT NULL DEFAULT true);CREATE TABLE app.ch05_technician (    technician_id integer PRIMARY KEY,    technician_name text NOT NULL,    specialty text NOT NULL,    team_code text NOT NULL);CREATE TABLE app.ch05_work_order (    work_order_id integer PRIMARY KEY,    customer_id integer NOT NULL REFERENCES app.ch05_customer(customer_id),    assigned_technician_id integer REFERENCES app.ch05_technician(technician_id),    status text NOT NULL CHECK (status IN ('queued','open','closed','cancelled')),    priority smallint NOT NULL CHECK (priority BETWEEN 1 AND 4),    city text NOT NULL,    opened_at timestamptz NOT NULL,    closed_at timestamptz,    estimated_minutes integer NOT NULL CHECK (estimated_minutes >= 0),    actual_minutes integer CHECK (actual_minutes >= 0),    cost numeric(10,2) NOT NULL CHECK (cost >= 0));CREATE TABLE app.ch05_work_order_tag (    work_order_id integer NOT NULL REFERENCES app.ch05_work_order(work_order_id),    tag text NOT NULL,    PRIMARY KEY (work_order_id, tag));CREATE TABLE app.ch05_work_order_event (    event_id integer PRIMARY KEY,    work_order_id integer NOT NULL REFERENCES app.ch05_work_order(work_order_id),    event_at timestamptz NOT NULL,    event_type text NOT NULL,    detail text);CREATE TABLE app.ch05_team (    team_id integer PRIMARY KEY,    team_name text NOT NULL UNIQUE,    parent_team_id integer REFERENCES app.ch05_team(team_id));INSERT INTO app.ch05_customer VALUES(1,'Northwind Health','North',true),(2,'Alpine Manufacturing','North',true),(3,'City Library','Central',true),(4,'Delta Foods','South',true),(5,'Evergreen School','South',false);INSERT INTO app.ch05_technician VALUES(10,'Ada','electrical','A'),(11,'Linus','network','A'),(12,'Grace','mechanical','B'),(13,'Ken','network','B');INSERT INTO app.ch05_work_order VALUES(1001,1,10,'closed',1,'Baku','2026-08-01 08:00+00','2026-08-01 10:00+00',90,110,240.00),(1002,1,11,'open',2,'Baku','2026-08-02 09:00+00',NULL,60,NULL,80.00),(1003,2,10,'closed',2,'Sumqayit','2026-08-02 07:30+00','2026-08-02 09:00+00',120,85,150.00),(1004,2,NULL,'queued',3,'Sumqayit','2026-08-03 12:00+00',NULL,45,NULL,50.00),(1005,3,12,'closed',1,'Baku','2026-08-01 06:00+00','2026-08-01 08:30+00',120,150,300.00),(1006,4,13,'cancelled',4,'Ganja','2026-08-04 14:00+00','2026-08-04 14:10+00',30,10,0.00),(1007,4,13,'open',2,'Ganja','2026-08-05 11:00+00',NULL,90,NULL,120.00),(1008,1,10,'closed',1,'Baku','2026-08-06 05:00+00','2026-08-06 06:00+00',60,55,90.00),(1009,3,12,'closed',2,'Baku','2026-08-06 09:00+00','2026-08-06 12:00+00',90,180,400.00),(1010,2,11,'open',1,'Baku','2026-08-07 10:00+00',NULL,0,NULL,75.00);INSERT INTO app.ch05_work_order_tag VALUES(1001,'electrical'),(1001,'urgent'),(1002,'network'),(1003,'electrical'),(1005,'mechanical'),(1005,'safety'),(1007,'network'),(1007,'urgent'),(1008,'electrical'),(1009,'mechanical'),(1009,'urgent'),(1010,'network');INSERT INTO app.ch05_work_order_event VALUES(1,1001,'2026-08-01 08:00+00','created','portal'),(2,1001,'2026-08-01 09:30+00','arrived','Ada on site'),(3,1001,'2026-08-01 10:00+00','closed','restored'),(4,1002,'2026-08-02 09:00+00','created','portal'),(5,1002,'2026-08-02 09:10+00','assigned','Linus'),(6,1003,'2026-08-02 07:30+00','created','monitoring'),(7,1003,'2026-08-02 09:00+00','closed','breaker replaced'),(8,1007,'2026-08-05 11:00+00','created','phone'),(9,1007,'2026-08-05 11:20+00','assigned','Ken'),(10,1008,'2026-08-06 05:00+00','created','monitoring'),(11,1008,'2026-08-06 06:00+00','closed','reset'),(12,1009,'2026-08-06 09:00+00','created','portal'),(13,1009,'2026-08-06 10:00+00','diagnosed','pump wear'),(14,1009,'2026-08-06 12:00+00','closed','pump replaced'),(15,1010,'2026-08-07 10:00+00','created','portal');INSERT INTO app.ch05_team VALUES(1,'Field Operations',NULL),(2,'Electrical',1),(3,'Network',1),(4,'Mechanical',1),(5,'Night Shift',3);

After setup, app.ch05_work_order contains 10 work orders, including one unassigned row, several NULL completion values, and one zero-minute estimate. Those details are intentional: they make outer joins, three-valued logic, NOT IN, scalar errors, and evaluation-order mistakes observable.

2. A CTE names a query result inside one statement

A nonrecursive CTE can make a query read like a pipeline: define active work, summarize it, then present the result. The name exists only for the statement. It is not a persisted view or temporary table.

sql · layer a report by relational grain
WITH active_work AS (    SELECT work_order_id, customer_id, cost    FROM app.ch05_work_order    WHERE status IN ('queued','open')),customer_rollup AS (    SELECT customer_id,           count(*) AS active_count,           sum(cost) AS active_cost    FROM active_work    GROUP BY customer_id)SELECT c.customer_name,       r.active_count,       r.active_costFROM customer_rollup rJOIN app.ch05_customer c USING (customer_id)ORDER BY r.active_cost DESC, c.customer_name;
text · expected result
customer_name         | active_count | active_cost----------------------+--------------+------------Alpine Manufacturing  | 2            | 125.00Delta Foods           | 1            | 120.00Northwind Health      | 1            | 80.00

The semantic value is naming and composition. Whether PostgreSQL physically stores the CTE output is a separate planner decision.

3. CTEs are not unconditional optimization fences

For a nonrecursive, side-effect-free SELECT, PostgreSQL can fold a singly referenced CTE into its parent so predicates and access paths can be optimized together. A multiply referenced CTE is normally evaluated once, but you can request NOT MATERIALIZED to allow folding, accepting possible duplicate computation. MATERIALIZED forces a separate CTE result and can intentionally block some pushdown.

sql · compare default, forced materialization, and forced folding
EXPLAIN (COSTS ON)WITH w AS (  SELECT * FROM app.ch05_work_order)SELECT work_order_idFROM wWHERE work_order_id = 1008;EXPLAIN (COSTS ON)WITH w AS MATERIALIZED (  SELECT * FROM app.ch05_work_order)SELECT work_order_idFROM wWHERE work_order_id = 1008;EXPLAIN (COSTS ON)WITH w AS NOT MATERIALIZED (  SELECT * FROM app.ch05_work_order)SELECT work_order_idFROM wWHERE work_order_id = 1008;

Do not label one form “faster” universally. Materialization can avoid repeated expensive work; folding can expose selective predicates and indexes. Volatile functions and data-modifying CTEs have additional semantics and are not interchangeable with an ordinary side-effect-free SELECT.

4. Recursive CTE = anchor + recursive term + termination

WITH RECURSIVE allows a CTE to refer to its own accumulated result. Think in iterations: the anchor creates the starting working set; the recursive term derives the next rows; execution stops when the recursive term produces no new rows for the working process.

sql · walk the ServiceHub team hierarchy from the root
WITH RECURSIVE org AS (    SELECT team_id,           team_name,           parent_team_id,           0 AS depth,           ARRAY[team_id] AS path    FROM app.ch05_team    WHERE team_id = 1    UNION ALL    SELECT child.team_id,           child.team_name,           child.parent_team_id,           parent.depth + 1,           parent.path || child.team_id    FROM app.ch05_team child    JOIN org parent ON child.parent_team_id = parent.team_id)SELECT team_id, team_name, depth, pathFROM orgORDER BY path;
text · expected hierarchy
team_id | team_name        | depth | path--------+------------------+-------+---------1       | Field Operations | 0     | {1}2       | Electrical       | 1     | {1,2}3       | Network          | 1     | {1,3}5       | Night Shift      | 2     | {1,3,5}4       | Mechanical       | 1     | {1,4}

The array path is both explanatory and useful for ordering. It also gives you the raw material to detect revisiting a node.

5. Deliberately wrong recursion: no termination rule

This query continually generates another integer. Do not execute unbounded recursion without a guard. In a disposable session, a short local statement_timeout can demonstrate the failure safely.

sql · controlled failure, then repair
BEGIN;SET LOCAL statement_timeout = '500ms';WITH RECURSIVE bad(n) AS (  VALUES (1)  UNION ALL  SELECT n + 1 FROM bad)SELECT max(n) FROM bad;ROLLBACK;-- Correct finite rule:WITH RECURSIVE ok(n) AS (  VALUES (1)  UNION ALL  SELECT n + 1 FROM ok WHERE n < 5)SELECT * FROM ok;
text · expected behavior
ERROR:  canceling statement due to statement timeout-- after rollback, finite query returns 1,2,3,4,5

A timeout is a safety net, not a business termination rule. Real recursion should be finite because the graph semantics and cycle policy make it finite.

6. Cycle-safe traversal with CYCLE

A parent-child table can still contain a cycle if constraints do not forbid one. PostgreSQL supports a SQL-standard-style CYCLE clause that tracks visited keys and exposes both an is_cycle marker and a path column. The rewritten recursion avoids continuing through rows already identified as cycles.

sql · inject a cycle transactionally and prove it is detected
BEGIN;-- Creates 1 -> 3 -> 5 -> 1 when traversing parent-to-child.UPDATE app.ch05_team SET parent_team_id = 5 WHERE team_id = 1;WITH RECURSIVE org(team_id, team_name, parent_team_id, depth) AS (    SELECT team_id, team_name, parent_team_id, 0    FROM app.ch05_team    WHERE team_id = 1  UNION ALL    SELECT child.team_id, child.team_name, child.parent_team_id, org.depth + 1    FROM app.ch05_team child    JOIN org ON child.parent_team_id = org.team_id)CYCLE team_id SET is_cycle USING cycle_pathSELECT team_id, team_name, depth, is_cycle, cycle_pathFROM orgORDER BY depth, team_id;ROLLBACK;

The exact path formatting is implementation-visible diagnostic output; the important contract is that the repeated team ID is marked as a cycle and recursion does not continue indefinitely through that branch. The rollback restores the original hierarchy.

7. SEARCH controls traversal ordering metadata, not recursion semantics

PostgreSQL also supports SEARCH DEPTH FIRST and SEARCH BREADTH FIRST clauses that synthesize an ordering column. They are useful when presentation order matters, but they do not change which rows logically belong to the recursive result. Ordering is applied when you sort by the generated key.

sql · depth-first ordering metadata
WITH RECURSIVE org(team_id, team_name, parent_team_id) AS (  SELECT team_id, team_name, parent_team_id  FROM app.ch05_team  WHERE team_id = 1  UNION ALL  SELECT c.team_id, c.team_name, c.parent_team_id  FROM app.ch05_team c  JOIN org p ON c.parent_team_id = p.team_id)SEARCH DEPTH FIRST BY team_id SET order_pathSELECT team_id, team_name, order_pathFROM orgORDER BY order_path;

8. Hands-on lab: separate readability, recursion, and planner control

sql · three verification tasks
-- A. Readable nonrecursive layer.WITH closed AS (  SELECT customer_id, cost FROM app.ch05_work_order WHERE status='closed')SELECT customer_id, count(*) AS n, sum(cost) AS totalFROM closedGROUP BY customer_idORDER BY customer_id;-- B. Hierarchy descendants of Network (team 3).WITH RECURSIVE descendants AS (  SELECT team_id, team_name, parent_team_id, 0 AS depth  FROM app.ch05_team WHERE team_id=3  UNION ALL  SELECT c.team_id, c.team_name, c.parent_team_id, d.depth+1  FROM app.ch05_team c  JOIN descendants d ON c.parent_team_id=d.team_id)SELECT * FROM descendants ORDER BY depth, team_id;-- C. Compare plan shape only after result correctness.EXPLAIN (COSTS ON)WITH w AS MATERIALIZED (SELECT * FROM app.ch05_work_order)SELECT * FROM w WHERE work_order_id=1001;

Check your understanding

  1. Why is “CTEs are always optimization fences” inaccurate in current PostgreSQL?
  2. What are the anchor and recursive terms responsible for?
  3. Why is statement_timeout not a substitute for a recursion termination rule?
  4. What does the CYCLE clause add to recursive traversal?
  5. When might MATERIALIZED help, and when might NOT MATERIALIZED help?
Review the answers

Current PostgreSQL can fold eligible side-effect-free CTEs into the parent query. The anchor seeds the working set; the recursive term derives later rows. A timeout only aborts runaway work, whereas correct recursion needs finite semantics. CYCLE tracks visited keys, marks repeated keys, and prevents endless traversal of that cycle. Materialization can avoid repeated expensive work or create a deliberate optimization boundary; NOT MATERIALIZED can expose parent predicates but may repeat computation.

9. Production judgment and bridge

Use CTEs to make relational stages explicit and recursive CTEs when the problem really is iterative graph/hierarchy expansion. Treat materialization keywords as targeted planner controls backed by evidence, not style preferences. The final lesson of this chapter composes complete result sets with UNION/INTERSECT/EXCEPT, PostgreSQL-specific DISTINCT ON, and row constructors.

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 assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.