Recursive CTEs in MySQL 8: Implementation Details and Pitfalls
AI generated
InnoDB
SQL
MySQL / CTEs
Recursive CTEs in MySQL 8
Implementation details, pitfalls, and a category tree walkthrough

Since MySQL 8, tree structures and other hierarchical data can be resolved directly in SQL through WITH RECURSIVE, without needing recursive application logic or a fixed number of self joins. The basic syntax follows the SQL standard, but MySQL brings a few implementation details of its own, from the internal handling as a UNION loop to the configurable infinite loop protection through cte_max_recursion_depth. This article covers how recursive CTEs work internally in MySQL, where the typical pitfalls sit, and how a Magento category tree can be resolved with one in practice, performantly.

11 min read WITH RECURSIVE Tree Structures

1. The basic structure of a recursive CTE

A recursive CTE consists of two subqueries joined by UNION or UNION ALL: the anchor member, which supplies the starting rows of the recursion, for example all root categories with no parent category, and the recursive member, which references itself by the CTE's own name and appends another level of the hierarchy on each pass. MySQL evaluates this structure by repeatedly running the recursive member against the most recently produced intermediate result, until no new rows appear.

Order matters here: the anchor member has to come before the recursive member, and both parts must produce exactly the same number of columns with compatible data types. Unlike a regular, non recursive CTE, the recursive part is also only allowed to reference the most recently produced intermediate result set, not the cumulative total of all previous passes, a detail that easily trips people up with more complex aggregation inside the recursion.

2. How MySQL implements the recursion internally as a UNION loop

Technically, MySQL implements WITH RECURSIVE as a repeated execution of the recursive part against a temporary work table that, after each pass, only contains the newly added rows, not the entire result so far. This work table is handled internally much like a regular derived table and typically kept as a MEMORY table as long as the data volume stays small enough, with an automatic switch to a disk based table once intermediate results grow larger.

In practice, that means the performance of a recursive CTE depends heavily on the size of the intermediate results per recursion level, not just on the total number of rows in the final result. A query that produces very many new rows per pass, for example a tree structure with high branching, can require noticeably more memory and time than a deeply nested but narrow structure, even at a comparatively modest overall depth.

3. MySQL specific quirks compared to other database systems

Compared to PostgreSQL or Oracle, MySQL notably does not allow aggregate functions, a GROUP BY clause, DISTINCT, or a further subquery that itself accesses the recursive CTE inside the recursive member. These restrictions exist because MySQL resolves the recursion strictly row by row through the work table and does not support intermediate aggregation across several recursion levels, which frequently requires adjustments when porting queries from other systems.

Also MySQL specific is that using UNION rather than UNION ALL between the anchor and the recursive member automatically forces duplicate detection across the entire result set built so far by default, adding overhead on large tree structures. As long as the underlying structure is guaranteed to be cycle free anyway, for example because a foreign key relationship structurally cannot create cycles, UNION ALL is almost always the faster and in practice usually sufficient choice.

4. Infinite loop protection via cte_max_recursion_depth

If the underlying data structure accidentally contains a cycle, for example a category that mistakenly references itself or one of its own child entries as its parent category, a recursive CTE without a safeguard would theoretically run forever. MySQL therefore caps the maximum recursion depth through the cte_max_recursion_depth system variable, which defaults to 1000 and can be adjusted both globally and per session.

Once this limit is exceeded, MySQL aborts the query with an explicit error message instead of letting it run unbounded and exhaust memory or disk space. For most tree structures in e-commerce, whose actual depth rarely exceeds a handful of levels, the default value already provides a comfortable safety margin that does not restrict real queries but reliably catches genuine cycles.


-- Check the current limit
SHOW VARIABLES LIKE 'cte_max_recursion_depth';

-- Deliberately raise it for a single session
-- when a genuinely very deep structure is expected
SET SESSION cte_max_recursion_depth = 5000;

5. Practical example: resolving the Magento category tree recursively

A Magento store's category tree lives in catalog_category_entity as a flat table with a parent_id foreign key, a classic adjacency list model. For a full path resolution, for example to build each category's complete path from root to current level as a readable string, a recursive CTE fits noticeably better than a fixed chain of self joins, which does not reliably work for variable tree depth anyway.

The anchor member selects the root categories, the recursive member joins each level to the previous one through parent_id and builds up both the depth and the full name path incrementally. For live use cases like a dynamic breadcrumb navigation or a category tree export feature, the same query can be reused almost unchanged.


WITH RECURSIVE category_path AS (
    SELECT entity_id, parent_id, name, 0 AS depth,
           CAST(name AS CHAR(500)) AS full_path
    FROM catalog_category_entity
    WHERE parent_id = 1

    UNION ALL

    SELECT c.entity_id, c.parent_id, c.name, cp.depth + 1,
           CONCAT(cp.full_path, ' / ', c.name)
    FROM catalog_category_entity c
    JOIN category_path cp ON c.parent_id = cp.entity_id
)
SELECT entity_id, depth, full_path
FROM category_path
ORDER BY full_path;

6. Performance tuning for deep or wide tree structures

The decisive index for a performant recursive category query sits on the parent_id column, since every recursion level is essentially a filtered search for all children of the rows found in the previous step. Without this index, every recursion level degenerates into a full table scan, which becomes noticeable quickly with thousands of categories, even if the actual tree depth stays low.

It is also worth formulating the anchor member as precisely as possible and not selecting more starting rows than actually needed, for example through an additional filter condition on is_active, if disabled categories are irrelevant to the use case anyway. A smaller anchor member directly reduces the size of every subsequent intermediate result set and thus the total recursion cost.

7. Common implementation mistakes in practice

A common mistake is trying to use an aggregate function like COUNT or SUM inside the recursive member, for example to keep a running count of child categories, which MySQL simply rejects syntactically. Such aggregations have to happen instead in a separate query layered on top of the recursive CTE, operating on the already fully resolved final result.

Equally common is a missing or wrongly sized data type on text based path columns, as in the full_path example above: if CAST(name AS CHAR(500)) is left out of the anchor member, MySQL derives the column width automatically from the shorter original column, silently truncating the path at deeper recursion levels without any error or warning.

8. Actively detecting cycles instead of only relying on the depth limit

The cte_max_recursion_depth safeguard prevents an uncontrolled crash, but it does not give an immediate diagnosis of which specific row caused the cycle. For data critical use cases, it is worth adding explicit cycle detection inside the CTE itself, for example by carrying along a path of already visited IDs and a stop condition once the current ID already appears in that path.

This technique adds a bit of extra complexity to the query, but in return delivers precise, immediately actionable information about which row is incorrectly referenced, which saves considerable time when debugging imported or manually maintained category data compared to merely catching the issue through the depth limit.


WITH RECURSIVE category_path AS (
    SELECT entity_id, parent_id,
           CAST(entity_id AS CHAR(2000)) AS visited
    FROM catalog_category_entity
    WHERE parent_id = 1

    UNION ALL

    SELECT c.entity_id, c.parent_id,
           CONCAT(cp.visited, ',', c.entity_id)
    FROM catalog_category_entity c
    JOIN category_path cp ON c.parent_id = cp.entity_id
    WHERE FIND_IN_SET(c.entity_id, cp.visited) = 0
)
SELECT * FROM category_path;

9. When a recursive CTE pays off and when alternatives are better

For occasional analysis, reports, and admin tools, a recursive CTE is almost always the clearest and most maintainable solution, because the entire tree logic sits in one place as readable SQL instead of being spread across several application layers. For very frequently called, load critical paths in the frontend, for example navigation on every single page request, a denormalized, materialized path column or a nested set model with precomputed left and right boundaries tends to be more performant.

The pragmatic rule of thumb: if a tree structure is read far more often than it changes, a one time materialization via a recursive CTE into a denormalized helper table is worth it, which is then queried directly without re running the recursion on every read. Only for genuinely rare, ad hoc queries is the direct recursive CTE on every call the simpler and sufficiently performant solution.

Aspect MySQL WITH RECURSIVE PostgreSQL Practical consequence
Aggregation inside the recursive member not allowed allowed move aggregation into a follow up query
Default with UNION instead of UNION ALL duplicate detection across the full result duplicate detection as well use UNION ALL for known cycle free structures
Infinite loop protection cte_max_recursion_depth, default 1000 no fixed default, application must self limit default is usually sufficient, raise if needed
Internal work table MEMORY, disk based when needed iterative execution without a fixed work table index on parent_id is decisive for performance
Explicit cycle detection must be built manually via a path column native CYCLE clause available in some cases add path tracking for critical data

Mironsoft

Database performance, index tuning, and Magento DB optimization

A Magento shop suffering from slow database queries?

We analyze MySQL databases for performance bottlenecks, optimize indexes and queries with purpose, and set up backup and replication strategies that actually work when it counts.

Performance Audit

Systematically investigate the slow query log and explain plans for bottlenecks.

Index Optimization

Build indexes with purpose for the shop's actual query load.

Backup Strategy

Set up reliable backup and restore processes for production Magento databases.

10. Summary

Recursive CTEs: Key Takeaways

Two part structure

The anchor member supplies starting rows, the recursive member only references the last intermediate result.

MySQL specific restrictions

No aggregation, no DISTINCT, and no subquery allowed inside the recursive member.

cte_max_recursion_depth as a safeguard

A default of 1000 reliably prevents uncontrolled infinite loops on cyclic data.

Index on parent_id is decisive

Without this index, every recursion level degenerates into a full table scan.

11. FAQ: Recursive CTEs: Key Takeaways

1What two parts make up a recursive CTE?
The anchor member, which supplies the starting rows, and the recursive member, which references itself and appends another level of the hierarchy on each pass, joined by UNION or UNION ALL.
2Why should UNION ALL be used instead of UNION on known cycle free data?
UNION forces duplicate detection across the entire result so far, adding overhead. On structurally cycle free data, UNION ALL delivers the same correct result faster.
3Which SQL constructs are not allowed inside the recursive member in MySQL?
Aggregate functions, GROUP BY, DISTINCT, and a further subquery that itself accesses the recursive CTE are not permitted inside the recursive part in MySQL.
4What happens when a recursive CTE exceeds the maximum recursion depth?
MySQL aborts the query with an explicit error message once the limit defined by cte_max_recursion_depth, 1000 levels by default, is exceeded.
5How can cte_max_recursion_depth be adjusted for a single query?
Through SET SESSION cte_max_recursion_depth = , the limit can be raised specifically for the current session without changing the global server configuration.
6Which index is decisive for a performant category tree recursion?
An index on the parent_id column, since every recursion level is essentially a filtered search for all children of the rows found in the previous step.
7Why can a text column in the recursion result get silently truncated?
If an explicit CAST to a sufficient character length is missing from the anchor member, MySQL derives the column width automatically from the original, often shorter column, causing silent truncation.
8How can a genuine cycle in the data be actively detected?
Through a carried along path column with all previously visited IDs and a stop condition once the current ID already appears in that path, instead of relying solely on the depth limit.
9When is materialization worth it instead of a direct recursive CTE?
When a tree structure is read far more often than it changes, a one time materialization into a denormalized helper table is worth it, which is then queried without re running the recursion.
10Is a recursive CTE suitable for load critical frontend navigation?
Not really directly on every page request. For such cases, a nested set model or a denormalized path column is usually more performant than a repeated live recursion.