Recursive CTE — Learn Number Sequences, BOM Expansion, Graph Paths, and Cycle Detection from the Basics
BasicWITH RECURSIVENumber / Date SequencesBOM / GraphsCycle DetectionPostgreSQL / MySQL 8 Compatible5 questions
QUESTION 6
Number Sequence and Running Total — Generate 1–N and a running total without a table
WITH RECURSIVESequence GenerationRunning TotalNo Table Required
Background

Recursive CTEs can also be used for pure numeric sequence generation without a table. Put a constant directly in the anchor member, then increment it in the recursive member to generate any desired sequence. This is a portable pattern for databases that do not provide generate_series().

WITH RECURSIVE series AS (
  -- ① Anchor member: no table reference; start from the constant 1
  SELECT 1 AS n

  UNION ALL

  -- ② Recursive member: add 1 to n; stop at the upper bound in WHERE
  SELECT n + 1
  FROM  series
  WHERE n + 1 <= 10
)
Evaluate the termination condition using the next value to generate: Writing WHERE n + 1 <= N directly checks whether the value for the next step is within the upper bound. This safely stops recursion after the row at the exact upper bound has been generated.
Problem

Generate the integer sequence from 1 through 10 with WITH RECURSIVE. Do not use any table. Return these three columns.

  • n: sequence number (1–10)
  • parity: 'Odd' or 'Even' (determined by n % 2)
  • running_sum: cumulative sum from 1 through n (for example, 1+2+3=6 when n=3)

Sort by n ascending.

Expected Output

Expected output (n ascending):

nparityrunning_sum
1Odd1
2Even3
3Odd6
4Even10
5Odd15
6Even21
7Odd28
8Even36
9Odd45
10Even55

The final running_sum of 55 matches Gauss's formula N(N+1)/2 = 10×11/2 = 55.

Model Answer
WITH RECURSIVE series AS (

  -- ① Anchor member: constants only (no table reference)
  SELECT
    1  AS n,
    1  AS running_sum

  UNION ALL

  -- ② Recursive member: add 1 to n and the next n to running_sum
  SELECT
    n + 1,
    running_sum + (n + 1)  -- running sum = previous running sum + next n
  FROM  series
  WHERE n + 1 <= 10        -- recurse only while n+1 is at most 10

)
SELECT
  n,
  CASE WHEN n % 2 = 1 THEN 'Odd' ELSE 'Even' END AS parity,
  running_sum
FROM  series
ORDER BY n;

/*
  Execution order (SQL logical evaluation order):
  1. Anchor member
  2. Recursion iterations 1–8
  3. Recursion iteration 9
  4. Recursion iteration 10 attempt
  5. Outer query
  */
Explanation (table transitions & key points)
WITH RECURSIVE series AS ( SELECT 1 AS n, 1 AS running_sum UNION ALL SELECT n + 1, running_sum + (n + 1) FROM series WHERE n + 1 <= 10 ) SELECT n, CASE WHEN n % 2 = 1 THEN 'Odd' ELSE 'Even' END AS parity, running_sum FROM series ORDER BY n;
LEGEND
Rows read / loaded
① Anchor Member
SELECT 1 AS n, 1 AS running_sumThe anchor member has no FROM clause and generates the constant n=1. This generated row becomes the first recursive step's working table (the current input).
1 / 5
Statenrunning_sum
New row (initialize working table)11
Anchor: 1 row
Recursive expansion from n=1 to 10 and running-sum propagation
STEP 1 Anchor member: generate the starting value
Initialize the working table (next input)
nrunning_sum
11
STEP 2 Recursive member: calculate and propagate the running sum
Working table (current input: n=1)
nrunning_sum
11
New row (next input: n=2)
n+1running_sum + (n+1)
21 + 2 = 3
New row (next input: n=3)
n+1running_sum + (n+1)
33 + 3 = 6
STEP 3 Evaluate the termination condition
Behavior at the upper bound
Working nNext n+1WHERE n+1 <= 10Result
91010 <= 10 (TRUE)Add n=10
101111 <= 10 (FALSE)No addition; stop
LEARNING POINTS
The anchor member can omit FROM: Standard SQL allows constants to be returned without a FROM clause, as in SELECT 1 AS n (common to PostgreSQL, MySQL, and SQLite). You can define a recursive starting point even when no table exists.
A running sum simply carries forward the previous row's value: running_sum + (n + 1) means "add the current n+1 to the previous row's running_sum." Value propagation is a core recursive CTE capability and produces the same result as the window function SUM() OVER (ORDER BY n).
PostgreSQL provides generate_series(): SELECT n, SUM(n) OVER (ORDER BY n) FROM generate_series(1,10) t(n) produces the same result. When portability is required, or on other DBMSs, WITH RECURSIVE is the standard approach.
ANTI-PATTERNS
Relying only on an outer-query LIMIT for termination: If you omit WHERE n + 1 <= 10 and use only LIMIT 10, a DBMS may expand rows beyond the upper bound internally before discarding them. In the worst case, this causes a cte_max_recursion_depth error. Always write the termination condition in the recursive member's WHERE clause.
Mismatched column types or counts between the anchor and recursive members: The SELECT lists of the anchor and recursive members must have exactly the same number, types, and order of columns. Different types cause a type-mismatch error. Add an explicit cast when string concatenation or another operation changes a type.
FIELD NOTE: Three major applications of sequence-generation CTEs
Numeric sequences are useful in many situations. ① Dynamic pivot-column generation: create month or weekday numbers as a sequence and combine them with CASE expressions for a pivoted aggregate. ② Bulk test-data insertion: INSERT INTO orders SELECT n, ... FROM series makes it easy to create many test records. ③ Date and time sequences: multiply a number by INTERVAL '1 day' to generate any desired date range (see Q8). All of these are safe and efficient as long as the termination condition is explicit.
QUESTION 7
BOM Expansion and Cumulative Cost — Calculate every component and manufacturing cost recursively
WITH RECURSIVEQuantity MultiplicationBOM / ManufacturingCumulative Multiplication
Background

Manufacturing and e-commerce systems often use a bill of materials (BOM), such as "Product A needs 2 units of component B, and component B needs 3 units of material C." To calculate the required quantity of every component for one finished product, recursively expand the structure while multiplying the parent quantity by the BOM quantity.

WITH RECURSIVE bom_tree AS (
  -- ① Anchor: start the finished product with qty=1
  SELECT part_id, part_name, unit_cost, 1 AS qty, 0 AS depth
  FROM  parts WHERE part_id = :root_id

  UNION ALL

  -- ② Recursive member: retrieve child parts from BOM and accumulate quantity by multiplication
  SELECT p.part_id, p.part_name, p.unit_cost,
         bt.qty * b.qty,          -- ← parent qty × BOM qty
         bt.depth + 1
  FROM  bom b
  JOIN  bom_tree bt ON b.parent_id = bt.part_id
  JOIN  parts p    ON b.child_id  = p.part_id
)
Multiplicative propagation across levels is the key to BOM quantities: Calculating bt.qty × b.qty one level at a time automatically handles quantity accumulation such as "1 parent → 4 children → 8 grandchildren." A BOM expansion propagates both addition (depth+1) and multiplication (qty×qty) at the same time.
Problem

There are product-structure tables (parts and bom). Expand every direct and indirect component of finished product A (part_id=1) and return depth, path, part_name, qty (cumulative quantity), unit_cost, line_cost (qty × unit_cost). Exclude finished product A itself and sort by depth, part_id ascending.

Tables used
▸ parts
part_idpart_nameunit_cost
1Finished Product A0
2Frame500
3Engine2000
4Tire300
5Piston200
6Valve50
▸ bom (bill of materials)
parent_idchild_idqty
121
131
144
358
3612
BOM structure:
Finished Product A(1) — qty1: Frame(2), qty1: Engine(3), qty4: Tire(4)
└ Engine(3) — qty8: Piston(5), qty12: Valve(6)
Expected Output

Expected output (depth, part_id ascending):

depthpathpart_nameqtyunit_costline_cost
1Finished Product A > FrameFrame1500500
1Finished Product A > EngineEngine120002000
1Finished Product A > TireTire43001200
2Finished Product A > Engine > PistonPiston82001600
2Finished Product A > Engine > ValveValve1250600

SUM(line_cost) = 500+2000+1200+1600+600 = 5900 is the total component cost per finished product A.

Model Answer
WITH RECURSIVE bom_tree AS (

  -- ① Anchor member: start Finished Product A (part_id=1) with qty=1, depth=0
  SELECT
    p.part_id,
    p.part_name,
    p.unit_cost,
    1                     AS qty,    -- start with one finished product
    0                     AS depth,
    p.part_name           AS path
  FROM  parts p
  WHERE p.part_id = 1

  UNION ALL

  -- ② Recursive member: retrieve child parts from BOM and accumulate quantity by multiplication
  SELECT
    p.part_id,
    p.part_name,
    p.unit_cost,
    bt.qty * b.qty        AS qty,   -- cumulative quantity = parent qty × BOM qty
    bt.depth + 1,
    bt.path || ' > ' || p.part_name
  FROM  bom b
  JOIN  bom_tree bt ON b.parent_id = bt.part_id  -- join parent part_id to BOM parent_id
  JOIN  parts p    ON b.child_id  = p.part_id   -- retrieve child-part details from parts

)
SELECT
  depth,
  path,
  part_name,
  qty,
  unit_cost,
  qty * unit_cost         AS line_cost  -- cumulative quantity × unit cost = line cost
FROM  bom_tree
WHERE part_id <> 1                      -- exclude the finished product itself (unit_cost=0)
ORDER BY depth, part_id;

/*
  Execution order (SQL logical evaluation order):
  1. Anchor member
  2. Recursion iteration 1
  3. Recursion iteration 2
  4. Recursion iteration 3
  5. Outer query
  */
Explanation (table transitions & key points)
WITH RECURSIVE bom_tree AS ( SELECT p.part_id, p.part_name, p.unit_cost, 1 AS qty, 0 AS depth, p.part_name AS path FROM parts p WHERE p.part_id = 1 UNION ALL SELECT p.part_id, p.part_name, p.unit_cost, bt.qty * b.qty AS qty, bt.depth + 1, bt.path || ' > ' || p.part_name FROM bom b JOIN bom_tree bt ON b.parent_id = bt.part_id JOIN parts p ON b.child_id = p.part_id ) SELECT depth, path, part_name, qty, unit_cost, qty * unit_cost AS line_cost FROM bom_tree WHERE part_id <> 1 ORDER BY depth, part_id;
LEGEND
Rows read / loaded
Excluded / hidden data
① Anchor Member (Finished Product A)
Start the finished product with qty=1, depth=0Initialize Finished Product A (part_id=1) with qty=1 (one unit). This generated row becomes the first recursive step's working table (current input), starting the BOM expansion.
1 / 4
Statepart_idpart_nameunit_costqtydepth
New row (initialize working table)1Finished Product A010
Anchor: 1 row (starting point)
Quantity multiplication across a BOM expansion
STEP 1 Anchor member: prepare the finished product (depth=0)
Initialize the working table (next input)
part_nameqtydepth
Finished Product A10
STEP 2 Recursion 1: expand direct components (depth=1)
Working table (current input)
part_nameParent qty
Finished Product A1
×
BOM structure
ComponentBOM qty
Frame1
Engine1
Tire4
=
New rows (next input)
part_nameQty calculationCumulative qty
Frame1 × 11
Engine1 × 11
Tire1 × 44
STEP 3 Recursion 2: expand grandchild components (depth=2)
Working table (current input: has children)
part_nameParent qty
Engine1
×
BOM structure
ComponentBOM qty
Piston8
Valve12
=
New rows (next input)
part_nameQty calculationCumulative qty
Piston1 × 88
Valve1 × 1212
LEARNING POINTS
Two JOINs distinguish this from a standard hierarchy CTE: A normal adjacency-list expansion uses one JOIN. A BOM expansion connects two tables: the intermediate table (bom) and the detail table (parts). When a bridge table is involved, remember that the pattern needs two JOINs.
Quantities grow exponentially in a multi-level BOM: If every level contains ×4 components across three levels, the deepest level contains 4×4×4=64 units. For large BOMs, consider a depth limit (WHERE depth < N) or partial expansion.
Aggregation makes total cost and the most expensive component easy to obtain: Wrapping this query with SELECT SUM(line_cost) calculates the total cost per finished product. Filtering with WHERE depth = 1 gives direct procurement cost, while calculating every level gives total material cost.
ANTI-PATTERNS
Adding quantities instead of multiplying them: bt.qty + b.qty (addition) gives a meaningless "1 parent + 4 children = 5" rather than the correct cumulative quantity. BOM expansion must propagate quantity by multiplication (×). The logic is "N parent units containing M child units require N×M child units per finished product."
A circular reference in the BOM causes an infinite loop: A design error such as "part A contains part B, and part B contains part A" prevents recursion from ending. In practice, always add a guard such as WHERE depth < 20 (see Q10 for cycle detection details).
FIELD NOTE: Three major BOM-expansion variations
BOM expansion is used across manufacturing, e-commerce, and game-item crafting. In practice, there are three variations. ① Flat expansion (this question): list every component and aggregate cost. ② Retrieve only the deepest leaf components: use WHERE part_id NOT IN (SELECT parent_id FROM bom) to create a "materials only" list. ③ Expand from a particular intermediate component: change the anchor member's WHERE clause to calculate only the cost of the Engine portion. The same recursive structure can be reused for all of these, which is a strength of WITH RECURSIVE.
QUESTION 8
Date Sequence and Gap Filling — Detect zero-sales days with a calendar CTE
WITH RECURSIVEDate GenerationGap FillingLEFT JOIN
Background

Reports and analysis often need to show an entire period, including days with no sales or time slots with no logs. Because the database contains rows only for days with sales, a standard pattern is to generate a date sequence with WITH RECURSIVE and fill gaps with LEFT JOIN.

WITH RECURSIVE cal AS (
  -- ① Anchor member: specify the start date (no table reference)
  SELECT DATE '2024-01-01' AS d

  UNION ALL

  -- ② Recursive member: add one day at a time through the end date
  SELECT (d + INTERVAL '1 day')::date   -- PostgreSQL
  -- SELECT DATE_ADD(d, INTERVAL 1 DAY)    -- MySQL
  FROM  cal
  WHERE d < DATE '2024-01-07'
)
Adding dates in PostgreSQL: (d + INTERVAL '1 day')::date needs a cast to date because interval addition produces a timestamp. MySQL uses DATE_ADD(d, INTERVAL 1 DAY), and SQLite uses DATE(d, '+1 day').
Problem

The sales_daily table contains rows only for days with sales. For all seven days from 2024-01-01 through 2024-01-07, return the amount when sales exist and 0 otherwise. Return sale_date, amount, status ('Present' or 'Missing') and sort by sale_date ascending.

Tables used
▸ sales_daily
sale_dateamount
2024-01-0115000
2024-01-0223000
2024-01-048000
2024-01-0630000
2024-01-0712000
2024-01-03 and 2024-01-05 are zero-sales days. After the LEFT JOIN, s.sale_date is NULL on those days.
Expected Output

Expected output (sale_date ascending):

sale_dateamountstatus
2024-01-0115000Present
2024-01-0223000Present
2024-01-030Missing
2024-01-048000Present
2024-01-050Missing
2024-01-0630000Present
2024-01-0712000Present

All seven days are returned. Missing days are filled with amount=0 and status='Missing'.

Model Answer
WITH RECURSIVE date_series AS (

  -- ① Anchor member: set the start date 2024-01-01 (no table reference)
  SELECT DATE '2024-01-01' AS d

  UNION ALL

  -- ② Recursive member: add one day at a time (cast to PostgreSQL date)
  -- MySQL: SELECT DATE_ADD(d, INTERVAL 1 DAY) FROM date_series WHERE d < '2024-01-07'
  SELECT (d + INTERVAL '1 day')::date
  FROM   date_series
  WHERE  d < DATE '2024-01-07'    -- stop when the end date is reached

)
SELECT
  ds.d                                     AS sale_date,
  COALESCE(s.amount, 0)                   AS amount,  -- fill NULL with 0
  CASE WHEN s.sale_date IS NULL
       THEN 'Missing' ELSE 'Present'
  END                                      AS status
FROM  date_series ds
LEFT JOIN sales_daily s ON ds.d = s.sale_date  -- no-sales days join as NULL
ORDER BY ds.d;

/*
  Execution order (SQL logical evaluation order):
  1. Anchor member
  2. Recursion iterations 1–5
  3. Recursion iteration 6
  4. Recursion iteration 7 attempt
  5. Outer query
  */
Explanation (table transitions & key points)
WITH RECURSIVE date_series AS ( SELECT DATE '2024-01-01' AS d UNION ALL SELECT (d + INTERVAL '1 day')::date FROM date_series WHERE d < DATE '2024-01-07' ) SELECT ds.d AS sale_date, COALESCE(s.amount, 0) AS amount, CASE WHEN s.sale_date IS NULL THEN 'Missing' ELSE 'Present' END AS status FROM date_series ds LEFT JOIN sales_daily s ON ds.d = s.sale_date ORDER BY ds.d;
LEGEND
Rows read / loaded
① Anchor Member
SELECT DATE '2024-01-01' AS dDefine the start date without a FROM clause. This generated first date becomes the first recursive step's working table (current input).
1 / 6
Stated (generated date)
New row (initialize working table)2024-01-01
Anchor: 1 row (set the start date)
Date generation → gap detection with LEFT JOIN
STEP 1 Generate the calendar (recursive CTE)
date_series (all generated dates)
dGeneration method
2024-01-01Anchor
2024-01-0201-01 + 1 day
2024-01-0301-02 + 1 day
......
2024-01-0701-06 + 1 day
STEP 2 Detect gaps with LEFT JOIN
date_series (left)
d
2024-01-01
2024-01-02
2024-01-03
2024-01-04
2024-01-05
sales_daily (right)
sale_dateamount
2024-01-0115000
2024-01-0223000
(not present)-
2024-01-048000
(not present)-
=
Join result and filling
ds.amountFilled (COALESCE)Classification (CASE)
2024-01-011500015000Present
2024-01-022300023000Present
2024-01-03NULL0Missing
2024-01-0480008000Present
2024-01-05NULL0Missing
LEARNING POINTS
LEFT JOIN is the key to detecting gaps: With date_series as the left table, LEFT JOIN sales_daily always preserves days without sales, leaving the sales-side columns as NULL. An INNER JOIN would remove missing days, so always use LEFT JOIN when every date must be present.
Use COALESCE and CASE for different jobs: COALESCE(amount, 0) converts NULL to a specified default. CASE WHEN sale_date IS NULL THEN 'Missing' converts the presence of NULL into a flag string. Use COALESCE for numeric filling and CASE for flagging as a standard practical pattern.
Apply the pattern to monthly and yearly aggregation: Change the termination condition to create a monthly axis (31 days) or yearly axis (365 days). Add EXTRACT(DOW FROM ds.d) for weekdays, or use GROUP BY date_trunc('week', ds.d) for weekly aggregation. Calendar CTEs are a broad foundation for analysis.
ANTI-PATTERNS
Forgetting the ::date cast in PostgreSQL: d + INTERVAL '1 day' returns a timestamp in PostgreSQL. The next recursive step then has a type mismatch with the date column and fails. Always cast with ::date. MySQL and SQLite return a DATE value and do not need this cast.
Using <= for the termination condition: With WHERE d <= '2024-01-07', recursion is attempted even when d='2024-01-07', adding '2024-01-08'. This produces one extra day. Use WHERE d < end_date so the condition is evaluated through the day before the end date.
FIELD NOTE: Analysis patterns using calendar CTEs
Calendar CTEs (date sequences) are standard building blocks for analytical SQL. ① Monthly sales trend report: LEFT JOIN every date in the month to sales to create a daily chart that includes zero-sales days. ② Day-of-week aggregation: add a weekday with EXTRACT(DOW FROM d) and GROUP BY to calculate average sales by Monday through Sunday. ③ Moving average: self-join the date sequence to provide the window structure for a seven-day moving average. Parameterizing the start and end dates turns this into a general-purpose analytical foundation.
QUESTION 9
Graph Traversal and Shortest Hops — Find the shortest paths between nodes in a directed graph
WITH RECURSIVEGraph TraversalPaths / BFSShortest Hops
Background

Directed graphs appear in organization workflows, transportation networks, and social-media follow relationships. Common questions are whether node A can reach node B and how many hops the shortest route takes. WITH RECURSIVE can find shortest hops in a form similar to breadth-first search (BFS).

WITH RECURSIVE paths AS (
  -- ① Anchor member: set directly reachable edges from the start at hops=1
  SELECT from_node, to_node, 1 AS hops,
         from_node || '->' || to_node AS path
  FROM  edges WHERE from_node = :start

  UNION ALL

  -- ② Recursive member: extend the path by one hop
  SELECT p.from_node, e.to_node, p.hops + 1,
         p.path || '->' || e.to_node
  FROM  edges e
  JOIN  paths p ON e.from_node = p.to_node
  WHERE p.hops + 1 <= 5   -- guard against infinite loops
)
Be careful with cyclic graphs: A graph containing a loop can revisit the same node indefinitely. A practical pattern is to use a guard such as hops <= N, or track visited nodes in the path string and prevent revisits with path NOT LIKE '%->X->%'.
Problem

There is a directed graph in the edges table. Find every node reachable from node 'A', its shortest hop count, and its path string. Return to_node, hops, path (excluding A itself), keep only the shortest path for each destination, and sort by hops, to_node ascending.

Tables used
▸ edges
from_nodeto_node
AB
AC
BD
CD
DE
BE
Graph structure: A→B, A→C, B→D, C→D, D→E, B→E
A→D has two paths: A→B→D (2 hops) and A→C→D (2 hops)
A→E has multiple paths: A→B→E (2 hops), A→B→D→E (3 hops), and A→C→D→E (3 hops)
Expected Output

Expected output (shortest hops only, hops, to_node ascending):

to_nodehopspath
B1A->B
C1A->C
D2A->B->D
E2A->B->E

D and E have multiple paths, but only the shortest-hop path is returned. If hop counts tie, choose the lexicographically earliest path.

Model Answer
WITH RECURSIVE paths AS (

  -- ① Anchor member: retrieve edges leaving node 'A' directly (hops=1)
  SELECT
    from_node,
    to_node,
    1                            AS hops,
    from_node || '->' || to_node AS path
  FROM  edges
  WHERE from_node = 'A'

  UNION ALL

  -- ② Recursive member: add an edge leaving the current to_node and extend by one hop
  SELECT
    p.from_node,
    e.to_node,
    p.hops + 1,
    p.path || '->' || e.to_node
  FROM  edges e
  JOIN  paths p ON e.from_node = p.to_node   -- move from the current endpoint
  WHERE p.hops + 1 <= 5                    -- depth guard against loops
    AND  p.path NOT LIKE '%' || e.to_node || '%'  -- prevent revisiting a node already in the path

)
, ranked AS (
  SELECT
    to_node,
    hops,
    path,
    ROW_NUMBER() OVER (
      PARTITION BY to_node
      ORDER BY hops, path
    ) AS rn  -- rank shortest hop first, then lexicographically smallest
  FROM  paths
)
SELECT
  to_node,
  hops,
  path
FROM  ranked
WHERE rn = 1
ORDER BY hops, to_node;

/*
  Execution order (SQL logical evaluation order):
  1. Anchor member
  2. Recursion iteration 1
  3. Recursion iteration 2
  4. ranked CTE (ROW_NUMBER)
  5. Outer query
  */
Explanation (table transitions & key points)
WITH RECURSIVE paths AS ( SELECT from_node, to_node, 1 AS hops, from_node || '->' || to_node AS path FROM edges WHERE from_node = 'A' UNION ALL SELECT p.from_node, e.to_node, p.hops + 1, p.path || '->' || e.to_node FROM edges e JOIN paths p ON e.from_node = p.to_node WHERE p.hops + 1 <= 5 AND p.path NOT LIKE '%' || e.to_node || '%' ) , ranked AS ( SELECT to_node, hops, path, ROW_NUMBER() OVER (PARTITION BY to_node ORDER BY hops, path) AS rn FROM paths ) SELECT to_node, hops, path FROM ranked WHERE rn = 1 ORDER BY hops, to_node;
LEGEND
Rows read / loaded
Excluded / hidden data
① Anchor Member (Start A)
edges WHERE from_node = 'A'Retrieve B and C, which are directly connected to A, and start them at hops=1. These two rows become the first recursive step's working table (current input).
1 / 4
Statefrom_nodeto_nodehopspath
New row (initialize working table)AB1A->B
New row (initialize working table)AC1A->C
Anchor: 2 rows (directly reachable nodes at hops=1)
Hop expansion during graph traversal
STEP 1 Anchor member: reach nodes from A (hops=1)
Initialize the working table (next input)
fromtohopspath
AB1A->B
AC1A->C
STEP 2 Recursive member: expand two-hop destinations
Expand from the working table (current input: B, C)
StartNext nodehopspath
BD2A->B->D
BE2A->B->E
CD2A->C->D
STEP 3 Select the shortest path (ROW_NUMBER())
Multiple paths to node D (cumulative result)
to_nodehopspath
D2A->B->D
D2A->C->D
Keep the row where ROW_NUMBER() = 1
to_nodehopspathrn
D2A->B->D1
LEARNING POINTS
Graph traversal with a recursive CTE is similar to BFS: At each iteration, WITH RECURSIVE replaces the working table with all nodes one hop farther away, resulting in behavior close to breadth-first search (BFS). Ranking with ROW_NUMBER() ordered by hops and path selects the shortest hop count and lexicographically smallest path together.
Prevent revisits with a path string: path NOT LIKE '%' || to_node || '%' checks whether a node is already included in the path string. If node names are numeric, search with delimiters such as '%->X->%' to prevent false matches. It is important to design identifiers so one node name is not a substring of another.
The same pattern can check reachability: Filter with WHERE to_node = 'E' before choosing the shortest path to ask whether A can reach E and to inspect all paths when it can. Reachability analysis in a graph is commonly used for access-control and dependency checks.
ANTI-PATTERNS
No revisit prevention in a graph with loops: If the graph contains a cycle such as A→B→A and you omit AND path NOT LIKE ..., recursion may never end and you can only rely on the depth guard. Always add either a path-string check or a depth limit when traversing a graph.
Applying a full scan to a large graph: Using this pattern unchanged on a graph with tens or hundreds of thousands of nodes can cause the number of expanded rows to explode. For large graphs, consider PostgreSQL's pgRouting extension or a dedicated graph database such as Neo4j. Recursive-CTE graph traversal is practical for small to medium graphs (up to several thousand nodes).
FIELD NOTE: Practical applications of graph traversal
Graph traversal with WITH RECURSIVE is particularly practical for medium-scale network analysis. ① Approval-workflow reachability: check the steps and path required for a request to reach the final approver. ② Dependency analysis: detect whether package A depends indirectly on package B in a software dependency graph. ③ Shared social-network paths: calculate how many hops connect two users in a follow-relationship graph. Dedicated tools become necessary as the graph grows, but SQL alone is sufficient for hundreds to a few thousand nodes.
QUESTION 10
Cycle Detection — Safely detect infinite loops with an is_cycle flag during recursive expansion
WITH RECURSIVECycle DetectionData QualityCYCLE Clause / Flag Management
Background

A recursive CTE enters an infinite loop when hierarchical data contains a cycle in which a node becomes its own ancestor. In practice, there are two approaches: ① the CYCLE clause in PostgreSQL 14+ and ② flag management with a visited-node array or path string. This question covers both.

-- CYCLE clause in PG14+ (most concise)
WITH RECURSIVE tree AS (
  SELECT id, parent_id, id AS root_id FROM nodes WHERE parent_id IS NULL
  UNION ALL
  SELECT n.id, n.parent_id, t.root_id FROM nodes n JOIN tree t ON n.parent_id = t.id
) CYCLE id SET is_cycle USING path  -- ← PG14+: detected rows have is_cycle TRUE
SELECT * FROM tree WHERE is_cycle;

-- Portable path-string check (works across DBMSs)
WHERE visited_ids NOT LIKE '%,' || child.id || ',%'  -- expand only unvisited nodes
The CYCLE clause is PostgreSQL 14+ only: It does not work in MySQL, SQLite, or SQL Server. Use a path-string check when portability is required. This question uses the portable path-check approach.
Problem

The nodes table contains a data inconsistency with a cycle. While expanding every node with WITH RECURSIVE, manage visited nodes in a path string and detect the cycle. Return node_id, name, parent_id, depth, path, is_cycle (is_cycle is TRUE when a cycle is detected and FALSE otherwise). Sort by node_id, depth ascending.

Tables used
▸ nodes
node_idnameparent_id
1RootNULL
2Alpha1
3Beta1
4Gamma2
4Gamma5
5Delta4
6OrphanNULL
Data inconsistency (cycle): assume an anomalous duplicate record was inserted with node_id=4 (Gamma) having 5 (Delta) as its parent.
This creates the cycle 1→2→4→5→4.
(The initial table data is normal, but this query demonstrates the detection logic when a cycle exists.)
Expected Output

Expected output (cycle included, node_id / depth ascending):

node_idnameparent_iddepthpathis_cycle
1RootNULL0,1,FALSE
2Alpha11,1,2,FALSE
3Beta11,1,3,FALSE
4Gamma22,1,2,4,FALSE
4Gamma54,1,2,4,5,4,TRUE
5Delta43,1,2,4,5,FALSE
6OrphanNULL0,6,FALSE

An is_cycle=TRUE row represents a revisit to a node already present in the path. Expansion is blocked after depth=4 by the guard.

Model Answer
WITH RECURSIVE tree AS (

  -- ① Anchor member: start from root nodes (parent_id IS NULL)
  SELECT
    node_id,
    name,
    parent_id,
    0                                   AS depth,
    ',' || node_id || ','              AS path,      -- record IDs with commas
    FALSE                               AS is_cycle  -- the anchor is not cyclic
  FROM  nodes
  WHERE parent_id IS NULL

  UNION ALL

  -- ② Recursive member: fetch children; set is_cycle=TRUE if path already contains the child
  SELECT
    n.node_id,
    n.name,
    n.parent_id,
    t.depth + 1,
    t.path || n.node_id || ','         AS path,
    t.path LIKE '%,' || n.node_id || ',%' AS is_cycle  -- detect ',node_id,' already in path
  FROM  nodes n
  JOIN  tree t ON n.parent_id = t.node_id
  WHERE t.depth + 1 <= 10                              -- depth guard (max 10 levels even with a cycle)
    AND  NOT t.is_cycle                                -- do not expand a row where a cycle was already detected

)
SELECT
  node_id,
  name,
  parent_id,
  depth,
  path,
  is_cycle
FROM  tree
ORDER BY node_id, depth;

/*
  Execution order (SQL logical evaluation order):
  1. Anchor member
  2. Recursion iteration 1
  3. Recursion iteration 2
  4. Recursion iteration 3
  5. Cycle detection
  6. Outer query
  */
Explanation (table transitions & key points)
WITH RECURSIVE tree AS ( SELECT node_id, name, parent_id, 0 AS depth, ',' || node_id || ',' AS path, FALSE AS is_cycle FROM nodes WHERE parent_id IS NULL UNION ALL SELECT n.node_id, n.name, n.parent_id, t.depth + 1, t.path || n.node_id || ',', t.path LIKE '%,' || n.node_id || ',%' AS is_cycle FROM nodes n JOIN tree t ON n.parent_id = t.node_id WHERE t.depth + 1 <= 10 AND NOT t.is_cycle ) SELECT node_id, name, parent_id, depth, path, is_cycle FROM tree ORDER BY node_id, depth;
LEGEND
Rows read / loaded
① Input Data (Source of the Cycle)
Anomalous state of the nodes tableAssume an anomalous duplicate record has entered an otherwise normal tree: Gamma (id=4) has Delta (id=5) as its parent. Trace how the resulting unintended loop path is detected.
1 / 6
node_idnameparent_idNotes
1RootNULLRoot
2Alpha1
3Beta1
4Gamma2Normal record
4Gamma5★ Anomalous duplicate record
5Delta4
6OrphanNULL
Inspect input data
How path strings detect cycles
STEP 1 Normal expansion (through depth 3)
Track the path changes
depthnode_idpathCheck: path LIKE '%,'||id||',%'is_cycle
01 (Root),1,-FALSE
12 (Alpha),1,2,',1,' + ',2,' → ✗FALSE
24 (Gamma),1,2,4,',1,2,' + ',4,' → ✗FALSE
35 (Delta),1,2,4,5,',1,2,4,' + ',5,' → ✗FALSE
STEP 2 Cycle-detection mechanism (depth 4)
Re-expand duplicate Gamma from the Delta working table
Parent pathExpanded nodeCheck: LIKE '%,4,%'is_cycleNOT t.is_cycle
,1,2,4,5,4',1,2,4,5,' + ',4,' → ✓ (detected)TRUEFALSE → expansion blocked
REFERENCE Comparison with PostgreSQL 14+ CYCLE clause
PG14+ CYCLE clause
FeatureDetails
SyntaxCYCLE id SET is_cycle USING path
AdvantageConcise, fast, DBMS-optimized
LimitationPG14+ only; unavailable on other DBMSs
vs
Path-string check (this question)
FeatureDetails
Syntaxpath LIKE '%,'||id||',%'
AdvantageWorks across DBMSs; highly portable
LimitationPerformance decreases when node names are long
LEARNING POINTS
Delimiter design for path strings matters: Wrapping IDs in commas, as in ','||id||',', prevents LIKE '%,12,%' from falsely matching node_id=1 or node_id=2 when searching for node_id=12. Using delimiters at both ends guarantees an exact token match and is the safe design.
Stop expansion immediately with AND NOT t.is_cycle: Recursive expansion after a row becomes is_cycle=TRUE has no business meaning. Adding AND NOT t.is_cycle to the recursive WHERE prevents unnecessary expansion. Using a flag column to control expansion also saves memory.
Use the PostgreSQL 14+ CYCLE clause when available: CYCLE node_id SET is_cycle USING path performs cycle detection in a DBMS-optimized form. Prefer CYCLE in PostgreSQL-only environments, and choose a path-string check in multi-DBMS environments.
ANTI-PATTERNS
Relying only on a depth guard: WHERE depth + 1 <= 10 alone does not detect a cycle or tell you which nodes form the loop. Path management or a CYCLE clause is required to detect and report cycles. The depth guard is only insurance that the query ends within N iterations at worst.
False node-ID matches with a LIKE pattern: An undelimited LIKE '%12%' also matches node_id=112 and node_id=123. Always surround IDs with a delimiter, such as LIKE '%,12,%'. For string IDs, choose a low-collision delimiter such as a hyphen.
FIELD NOTE: Why cycle detection and data quality matter
A cycle such as this one is a data inconsistency that can arise unintentionally from an application bug or an operational mistake. For example, an organization hierarchy might contain a record saying that person A manages person B while person B manages person A. Running a periodic cycle check with a recursive CTE can detect data-quality degradation early. In PostgreSQL, a script that combines the CYCLE clause with WHERE is_cycle = TRUE to alert on those rows is effective. As a fundamental safeguard, applications should check before INSERT/UPDATE, using WITH RECURSIVE, that the new parent_id is not already one of the node's descendants.