AD JB Toolbox 01 EN

MariaDB Tutorial / Query Hierarchies and Prevent Cycles with Recursive CTEs

A recursive common table expression, or recursive CTE, starts with an initial row set and repeatedly follows relationships to produce further rows. A reliable hierarchy query needs an anchor, a stopping rule, sufficiently wide path columns, cycle handling, and an explicit final sort.

Know when recursion adds value

An ordinary CTE names a query step. A recursive CTE also references that name inside its own definition: the anchor creates the first rows, and the recursive member finds the next rows from the preceding step. This example combines them with UNION ALL. Break Queries into Named Steps with WITH and CTEs introduces statement-scoped intermediate results; Design Single and Composite Indexes for Real Queries helps examine the access cost of locating children by parent key.

Category trees and organization charts can have variable depth, making recursive traversal useful. A fixed two-level relationship may be simpler with a JOIN. A large hierarchy also needs an index for child lookup and a deliberate depth bound. The MariaDB recursive CTE overview explains the iterative computation and anchor-column types.

Prepare a six-row category tree

Run this setup in the dedicated appops_lab schema using an administrator or a lab account with schema-creation privileges. If category_tree already exists, inspect the prior exercise before repeating its creation. The foreign key enforces the existence of a parent; it does not prove that the complete hierarchy is acyclic.

CREATE DATABASE IF NOT EXISTS appops_lab
  CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE appops_lab;
CREATE TABLE category_tree (
  category_id INT NOT NULL PRIMARY KEY,
  parent_id INT NULL,
  category_name VARCHAR(40) NOT NULL,
  INDEX ix_parent (parent_id),
  CONSTRAINT fk_category_parent FOREIGN KEY (parent_id)
    REFERENCES category_tree(category_id)
) ENGINE=InnoDB;
INSERT INTO category_tree VALUES
  (1, NULL, 'Catalog'), (2, 1, 'Books'), (3, 1, 'Tools'),
  (4, 2, 'SQL'), (5, 2, 'Python'), (6, 3, 'Editors');

Catalog contains Books and Tools. Books contains SQL and Python; Tools contains Editors. The parent_id index supports finding children during traversal. With only six rows, this lab demonstrates relationships and correctness rather than making general speed claims.

A depth-zero Catalog root with Books and Tools at depth one and SQL, Python, and Editors at depth two

Run a complete query with depth and path tracking

WITH RECURSIVE tree AS (
  SELECT category_id, parent_id, category_name, 0 AS depth,
         CAST(CONCAT('/', category_id, '/') AS CHAR(1000)) AS visited,
         CAST(category_name AS CHAR(1000)) AS name_path
  FROM category_tree WHERE category_id = 1
  UNION ALL
  SELECT c.category_id, c.parent_id, c.category_name, t.depth + 1,
         CONCAT(t.visited, c.category_id, '/'),
         CONCAT(t.name_path, ' > ', c.category_name)
  FROM category_tree c JOIN tree t ON c.parent_id = t.category_id
  WHERE t.depth < 8
    AND LOCATE(CONCAT('/', c.category_id, '/'), t.visited) = 0
)
SELECT category_id, parent_id, depth, name_path
FROM tree ORDER BY depth, category_id;

The first SELECT returns category 1 at depth zero. The recursive SELECT finds children whose parent_id matches the current category and increments depth. The visited path surrounds numeric identifiers with slashes, so checking ID 1 does not accidentally match ID 11.

Both path columns are explicitly cast to CHAR(1000) in the anchor. If an initial short string determines a narrow column type, later concatenated paths can overflow it. This example assumes integer IDs, short names, and a maximum depth of eight. Recalculate the width and bounds for larger real-world paths instead of treating 1000 as a universal safe value.

ID Parent Depth Path
1 NULL 0 Catalog
2 1 1 Catalog > Books
3 1 1 Catalog > Tools
4 2 2 Catalog > Books > SQL
5 2 2 Catalog > Books > Python
6 3 2 Catalog > Tools > Editors

Only the final ORDER BY depth, category_id guarantees this presentation order. Do not infer display order from recursive evaluation. Sorting a text path also produces lexical ordering, which can differ from the numeric ordering of category IDs.

Change one condition and inspect the scope

Change only t.depth < 8 to t.depth < 1 and rerun the query. It includes the depth-zero anchor and its immediate children: IDs 1, 2, and 3. Restore the bound to eight afterward. Changing the anchor to category_id = 2 instead returns the Books subtree: IDs 2, 4, and 5, with Books now at depth zero.

A result cut off by the depth limit is not necessarily a complete hierarchy. Determine whether the actual structure is deeper before increasing the bound. The server’s max_recursive_iterations variable is another limit on repeated computation; it does not replace a business stopping rule or validate the parent relationships.

Introduce a cycle and roll it back

In the same connection, run these two statements and then rerun the complete recursive query above. Another connection might not see the uncommitted parent change.

START TRANSACTION;
UPDATE category_tree SET parent_id = 4 WHERE category_id = 1;

Assigning parent 4 to category 1 creates a 1 → 2 → 4 → 1 cycle. Because this query explicitly starts at category 1, its visited-ID test excludes the return to that already visited row and still finishes with six rows. Restore the original data immediately after observing it:

ROLLBACK;
SELECT category_id, parent_id FROM category_tree WHERE category_id = 1;

The parent must be NULL again. A query anchored on parent_id IS NULL could miss an entire cyclic component after its root disappears. Therefore, avoiding revisits during one traversal does not prove that the complete database has no cycles. MariaDB also offers CYCLE ... RESTRICT syntax; the explicit path here makes the traversal rule visible.

Check correctness before using a production hierarchy

  • Confirm that the anchor selects exactly the intended root or roots.
  • Check missing parents, self-parent rows, cycles, and unreachable components as different conditions.
  • Choose a maximum depth and calculate a compatible path width.
  • Inspect the child-lookup index and EXPLAIN, and reconcile the resulting row count with the expected relationships.

To clean up, confirm that this article’s table is disposable and run DROP TABLE appops_lab.category_tree;. That schema change is not undone by an ordinary ROLLBACK. Do not substitute a business table name into the cleanup command.

More in This Category
MariaDB Tutorial / Filter Rows with WHERE, AND, OR, IN, BETWEEN, and LIKE

MariaDB Tutorial / Filter Rows with WHERE, AND, OR, IN, BETWEEN, and LIKE

Build clear MariaDB filters using comparisons, grouped AND/OR conditions, IN lists, inclusive ranges and escaped LIKE patterns. Check expected product IDs and investigate missing or extra rows.

MariaDB Tutorial / Identify Rows with PRIMARY KEY, AUTO_INCREMENT, and UNIQUE

MariaDB Tutorial / Identify Rows with PRIMARY KEY, AUTO_INCREMENT, and UNIQUE

Use primary keys, generated identifiers, and unique constraints for different purposes. Practice nullable unique email values, composite keys, duplicate failures, and the identifier gap left by a rolled-back insert.

MariaDB Tutorial / Schedule Database Tasks and Verify Event Results

MariaDB Tutorial / Schedule Database Tasks and Verify Event Results

Create a disabled one-time event, reschedule it into the future, and verify both execution metadata and its result row. Understand recurring-task overlap, retries, privileges, time zones, and cleanup.

MariaDB Tutorial / Normalize an Order Database Without Losing Historical Facts

MariaDB Tutorial / Normalize an Order Database Without Losing Historical Facts

Analyze order-data dependencies and observe an update anomaly in a flattened copy. Separate current attributes from transaction snapshots and review keys, normal forms and deliberate denormalization.

MariaDB Tutorial / Restore a Backup into a Separate Database and Verify It

MariaDB Tutorial / Restore a Backup into a Separate Database and Verify It

Review a SQL dump, restore it into a verified empty database and compare object definitions, rows and goods totals. Handle source-file errors, compatibility and partial-restoration states explicitly.

MariaDB Tutorial / Import and Export CSV with Correct Paths, Types, and Encoding

MariaDB Tutorial / Import and Export CSV with Correct Paths, Types, and Encoding

Stage and validate a UTF-8 CSV before converting IDs and prices, then export through a server-side path. Review quoting, warnings, normalized duplicates, numeric bounds and field-level round-trip checks.

MariaDB Tutorial / Choose String Types: CHAR, VARCHAR, and TEXT

MariaDB Tutorial / Choose String Types: CHAR, VARCHAR, and TEXT

Compare CHAR, VARCHAR, and TEXT with runnable Unicode and trailing-space examples. Check character counts versus byte counts, length errors, comparison rules, and the optional TEXT(M) syntax.

MariaDB Tutorial / Store Dates and Times with DATE, DATETIME, and TIMESTAMP

MariaDB Tutorial / Store Dates and Times with DATE, DATETIME, and TIMESTAMP

Choose DATE, DATETIME, or TIMESTAMP by the meaning of a time value. Run a controlled time-zone comparison, check version-dependent limits and invalid dates, and restore the original session settings.

MariaDB Tutorial / Understand Replication, Lag, Errors, and Failover

MariaDB Tutorial / Understand Replication, Lag, Errors, and Failover

Prepare a two-server local Compose lab and interpret replication status, lag and errors. Observe apply-thread pause/resume behavior and distinguish replication from backup and controlled failover.

MariaDB Tutorial / Join, Extract, Replace, and Measure Strings

MariaDB Tutorial / Join, Extract, Replace, and Measure Strings

Practice concatenation, substring extraction, replacement, trimming and string length in MariaDB. Compare NULL and empty inputs and distinguish Unicode characters from UTF-8 byte counts.