VillageSQL is a drop-in replacement for MySQL with extensions.
All examples on this page work on VillageSQL. Install Now →
optimizer_switch settings, so your numbers will differ. The shape of each
plan is the part that transfers.
The rule: mergeable or not
A CTE is either folded into the outer query or built as a temporary table first, and which you get is decided by what the CTE contains rather than by how many times you name it. A CTE whose body is a plainSELECT with WHERE and JOIN merges, and
disappears from the plan entirely:
GROUP BY, DISTINCT, LIMIT, UNION or a window function cannot merge,
because the outer query’s conditions are not equivalent when applied before
those operations. Such a CTE is materialized.
The plans below show that second case. Read them as being about the CTE’s
contents, not about the word WITH.
Check which one you got
EXPLAIN FORMAT=TREE says so in as many words. The default EXPLAIN does not:
its Extra column does not carry the word on the queries here.
Materialize CTE counts, even though the CTE is named once. The GROUP BY
inside it is what forces that, and the server writes the rows to a temporary
table and reads them back.
A derived table containing the same GROUP BY gets the same plan. Written
as FROM (SELECT rating, COUNT(*) ... GROUP BY rating) AS counts, the tree is
identical apart from the node’s name, and the cost is the same. So on a
single-reference query the CTE beats the derived table by nothing: the 2 forms
differ in how they read, and the optimizer treats them alike.
Reference the same CTE twice and the wording changes to if needed, with the
second reference reusing the same materialized result:
What materializing costs you
A materialized CTE is a temporary table, which means 2 things worth knowing. It gives up the base tables’ indexes. Once the rows sit in a temporary table, the outer query scans that table. The server may build anauto_key on the
fly, as it did for the join above, and that is not the index you had on the
real table. Compare the merged plan at the top of this page, where the outer
condition reached PRIMARY.
Pushdown softens this. derived_condition_pushdown is on by default and moves
what it can inside, which is why Filter: (count(0) > 200) appears inside
the Materialize node above rather than outside it. It cannot move everything,
so when a plan shows the CTE producing far more rows than the result keeps,
write the restriction inside the CTE yourself.
When a CTE is the wrong shape
- A single short step. A derived table keeps it where it is used. Naming a 1-line subquery adds a hop for the reader.
- A filter the optimizer did not push down. If the plan shows the CTE materializing every row and the outer query discarding most of them, write the filter inside.
- Row-by-row work against an index. A correlated subquery or a join can use the index on each lookup. A materialized CTE has already given that up.
FROM
clause to run it by hand is the same work done manually every time.
Recursive CTEs on real hierarchies
Sakila has no hierarchy, so this section uses a table of its own. Create it beside the sample data and runDROP TABLE employees; when you have finished
with it:
depth column is both the answer and the guard.
WHERE oc.depth < 100 in the query above is what prevents that. It turns an
unbounded walk into a bounded one, and it works whether or not the data is
clean.
The error message suggests raising cte_max_recursion_depth, which is right
only when the recursion genuinely terminates and needs more rounds than
the default 1,000. A legitimate 1,500-step series hits the same error, and
SET SESSION cte_max_recursion_depth = 5000 is the correct answer there. On
cyclic data it is the wrong one: the query still never ends, and fails later instead.
Availability and terminology
CTEs arrived in MySQL 8.0 and are present in every release since, including 8.4 and 9.x. A server that answersERROR 1064 to a WITH clause predates
them; check with SELECT VERSION().
The standard calls the first part of a recursive CTE the anchor member and
the second the recursive member. The tutorial calls the first one the seed,
which is the same thing, and MySQL’s own messages use the standard terms.
CTEs work in INSERT, UPDATE and DELETE
Troubleshooting
See also
- Subqueries vs JOINs in MySQL — the other shape this competes with
- MySQL GROUP BY performance — the temporary table a CTE can help you avoid
- MySQL views — persisting a named query as a schema object instead
- Common table expressions — the tutorial, if you want the syntax first

