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.
Table of Contents
- 1. The basic structure of a recursive CTE
- 2. How MySQL implements the recursion internally as a UNION loop
- 3. MySQL specific quirks compared to other database systems
- 4. Infinite loop protection via cte_max_recursion_depth
- 5. Practical example: resolving the Magento category tree recursively
- 6. Performance tuning for deep or wide tree structures
- 7. Common implementation mistakes in practice
- 8. Actively detecting cycles instead of only relying on the depth limit
- 9. When a recursive CTE pays off and when alternatives are better
- 10. Summary
- 11. FAQ
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.