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 )
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.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 (n ascending):
| n | parity | running_sum |
|---|---|---|
| 1 | Odd | 1 |
| 2 | Even | 3 |
| 3 | Odd | 6 |
| 4 | Even | 10 |
| 5 | Odd | 15 |
| 6 | Even | 21 |
| 7 | Odd | 28 |
| 8 | Even | 36 |
| 9 | Odd | 45 |
| 10 | Even | 55 |
The final running_sum of 55 matches Gauss's formula N(N+1)/2 = 10×11/2 = 55.
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 */
LEGEND
① 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).| State | n | running_sum |
|---|---|---|
| New row (initialize working table) | 1 | 1 |
| n | running_sum |
|---|---|
| 1 | 1 |
| n | running_sum |
|---|---|
| 1 | 1 |
| n+1 | running_sum + (n+1) |
|---|---|
| 2 | 1 + 2 = 3 |
| n+1 | running_sum + (n+1) |
|---|---|
| 3 | 3 + 3 = 6 |
| Working n | Next n+1 | WHERE n+1 <= 10 | Result |
|---|---|---|---|
| 9 | 10 | 10 <= 10 (TRUE) | Add n=10 |
| 10 | 11 | 11 <= 10 (FALSE) | No addition; stop |
SELECT 1 AS n (common to PostgreSQL, MySQL, and SQLite). You can define a recursive starting point even when no table exists.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).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.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.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.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 )
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.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.
| part_id | part_name | unit_cost |
|---|---|---|
| 1 | Finished Product A | 0 |
| 2 | Frame | 500 |
| 3 | Engine | 2000 |
| 4 | Tire | 300 |
| 5 | Piston | 200 |
| 6 | Valve | 50 |
| parent_id | child_id | qty |
|---|---|---|
| 1 | 2 | 1 |
| 1 | 3 | 1 |
| 1 | 4 | 4 |
| 3 | 5 | 8 |
| 3 | 6 | 12 |
Finished Product A(1) — qty1: Frame(2), qty1: Engine(3), qty4: Tire(4)
└ Engine(3) — qty8: Piston(5), qty12: Valve(6)
Expected output (depth, part_id ascending):
| depth | path | part_name | qty | unit_cost | line_cost |
|---|---|---|---|---|---|
| 1 | Finished Product A > Frame | Frame | 1 | 500 | 500 |
| 1 | Finished Product A > Engine | Engine | 1 | 2000 | 2000 |
| 1 | Finished Product A > Tire | Tire | 4 | 300 | 1200 |
| 2 | Finished Product A > Engine > Piston | Piston | 8 | 200 | 1600 |
| 2 | Finished Product A > Engine > Valve | Valve | 12 | 50 | 600 |
SUM(line_cost) = 500+2000+1200+1600+600 = 5900 is the total component cost per finished product A.
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 */
LEGEND
① 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.| State | part_id | part_name | unit_cost | qty | depth |
|---|---|---|---|---|---|
| New row (initialize working table) | 1 | Finished Product A | 0 | 1 | 0 |
| part_name | qty | depth |
|---|---|---|
| Finished Product A | 1 | 0 |
| part_name | Parent qty |
|---|---|
| Finished Product A | 1 |
| Component | BOM qty |
|---|---|
| Frame | 1 |
| Engine | 1 |
| Tire | 4 |
| part_name | Qty calculation | Cumulative qty |
|---|---|---|
| Frame | 1 × 1 | 1 |
| Engine | 1 × 1 | 1 |
| Tire | 1 × 4 | 4 |
| part_name | Parent qty |
|---|---|
| Engine | 1 |
| Component | BOM qty |
|---|---|
| Piston | 8 |
| Valve | 12 |
| part_name | Qty calculation | Cumulative qty |
|---|---|---|
| Piston | 1 × 8 | 8 |
| Valve | 1 × 12 | 12 |
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.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."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.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' )
(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').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.
| sale_date | amount |
|---|---|
| 2024-01-01 | 15000 |
| 2024-01-02 | 23000 |
| 2024-01-04 | 8000 |
| 2024-01-06 | 30000 |
| 2024-01-07 | 12000 |
Expected output (sale_date ascending):
| sale_date | amount | status |
|---|---|---|
| 2024-01-01 | 15000 | Present |
| 2024-01-02 | 23000 | Present |
| 2024-01-03 | 0 | Missing |
| 2024-01-04 | 8000 | Present |
| 2024-01-05 | 0 | Missing |
| 2024-01-06 | 30000 | Present |
| 2024-01-07 | 12000 | Present |
All seven days are returned. Missing days are filled with amount=0 and status='Missing'.
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 */
LEGEND
① 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).| State | d (generated date) |
|---|---|
| New row (initialize working table) | 2024-01-01 |
| d | Generation method |
|---|---|
| 2024-01-01 | Anchor |
| 2024-01-02 | 01-01 + 1 day |
| 2024-01-03 | 01-02 + 1 day |
| ... | ... |
| 2024-01-07 | 01-06 + 1 day |
| d |
|---|
| 2024-01-01 |
| 2024-01-02 |
| 2024-01-03 |
| 2024-01-04 |
| 2024-01-05 |
| sale_date | amount |
|---|---|
| 2024-01-01 | 15000 |
| 2024-01-02 | 23000 |
| (not present) | - |
| 2024-01-04 | 8000 |
| (not present) | - |
| d | s.amount | Filled (COALESCE) | Classification (CASE) |
|---|---|---|---|
| 2024-01-01 | 15000 | 15000 | Present |
| 2024-01-02 | 23000 | 23000 | Present |
| 2024-01-03 | NULL | 0 | Missing |
| 2024-01-04 | 8000 | 8000 | Present |
| 2024-01-05 | NULL | 0 | Missing |
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.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.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.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.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.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.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 )
hops <= N, or track visited nodes in the path string and prevent revisits with path NOT LIKE '%->X->%'.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.
| from_node | to_node |
|---|---|
| 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 (shortest hops only, hops, to_node ascending):
| to_node | hops | path |
|---|---|---|
| B | 1 | A->B |
| C | 1 | A->C |
| D | 2 | A->B->D |
| E | 2 | A->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.
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 */
LEGEND
① 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).| State | from_node | to_node | hops | path |
|---|---|---|---|---|
| New row (initialize working table) | A | B | 1 | A->B |
| New row (initialize working table) | A | C | 1 | A->C |
| from | to | hops | path |
|---|---|---|---|
| A | B | 1 | A->B |
| A | C | 1 | A->C |
| Start | Next node | hops | path |
|---|---|---|---|
| B | D | 2 | A->B->D |
| B | E | 2 | A->B->E |
| C | D | 2 | A->C->D |
| to_node | hops | path |
|---|---|---|
| D | 2 | A->B->D |
| D | 2 | A->C->D |
| to_node | hops | path | rn |
|---|---|---|---|
| D | 2 | A->B->D | 1 |
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.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.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.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 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.
| node_id | name | parent_id |
|---|---|---|
| 1 | Root | NULL |
| 2 | Alpha | 1 |
| 3 | Beta | 1 |
| 4 | Gamma | 2 |
| 4 | Gamma | 5 |
| 5 | Delta | 4 |
| 6 | Orphan | NULL |
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 (cycle included, node_id / depth ascending):
| node_id | name | parent_id | depth | path | is_cycle |
|---|---|---|---|---|---|
| 1 | Root | NULL | 0 | ,1, | FALSE |
| 2 | Alpha | 1 | 1 | ,1,2, | FALSE |
| 3 | Beta | 1 | 1 | ,1,3, | FALSE |
| 4 | Gamma | 2 | 2 | ,1,2,4, | FALSE |
| 4 | Gamma | 5 | 4 | ,1,2,4,5,4, | TRUE |
| 5 | Delta | 4 | 3 | ,1,2,4,5, | FALSE |
| 6 | Orphan | NULL | 0 | ,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.
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 */
LEGEND
① 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.| node_id | name | parent_id | Notes |
|---|---|---|---|
| 1 | Root | NULL | Root |
| 2 | Alpha | 1 | |
| 3 | Beta | 1 | |
| 4 | Gamma | 2 | Normal record |
| 4 | Gamma | 5 | ★ Anomalous duplicate record |
| 5 | Delta | 4 | |
| 6 | Orphan | NULL |
| depth | node_id | path | Check: path LIKE '%,'||id||',%' | is_cycle |
|---|---|---|---|---|
| 0 | 1 (Root) | ,1, | - | FALSE |
| 1 | 2 (Alpha) | ,1,2, | ',1,' + ',2,' → ✗ | FALSE |
| 2 | 4 (Gamma) | ,1,2,4, | ',1,2,' + ',4,' → ✗ | FALSE |
| 3 | 5 (Delta) | ,1,2,4,5, | ',1,2,4,' + ',5,' → ✗ | FALSE |
| Parent path | Expanded node | Check: LIKE '%,4,%' | is_cycle | NOT t.is_cycle |
|---|---|---|---|---|
| ,1,2,4,5, | 4 | ',1,2,4,5,' + ',4,' → ✓ (detected) | TRUE | FALSE → expansion blocked |
| Feature | Details |
|---|---|
| Syntax | CYCLE id SET is_cycle USING path |
| Advantage | Concise, fast, DBMS-optimized |
| Limitation | PG14+ only; unavailable on other DBMSs |
| Feature | Details |
|---|---|
| Syntax | path LIKE '%,'||id||',%' |
| Advantage | Works across DBMSs; highly portable |
| Limitation | Performance decreases when node names are long |
','||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.AND NOT t.is_cycle to the recursive WHERE prevents unnecessary expansion. Using a flag column to control expansion also saves memory.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.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.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.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.