Skip to main content

VillageSQL is a drop-in replacement for MySQL with extensions.

All examples on this page work on VillageSQL. Install Now →
A CTE and a derived table return the same rows, so the choice between them is usually about reading. It stops being only about reading when the optimizer materializes one, which it does more often than most write-ups suggest. This guide is about telling which you got, and about the cases where a CTE is the wrong shape. The tutorial teaches the syntax itself: common table expressions, recursive CTEs and derived tables. Every plan on this page came from 1 server against the Sakila sample database. Costs and row estimates come from InnoDB’s sampled statistics and from your 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 plain SELECT with WHERE and JOIN merges, and disappears from the plan entirely:
No CTE node, and the outer condition reached the index. A CTE containing 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:
That is where the 2 forms genuinely part company. The expensive inner query runs once and both references read the result. Written as derived tables the same query names the subquery twice, and computes it twice.

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 an auto_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.
A CTE is the right shape when the query has 2 or more named stages, when the same result is needed twice, or when the alternative is a subquery nested deep enough that nobody can see where it ends. It is also the easier of the 2 to debug. A CTE definition can be run on its own by selecting from it, which tells you whether the first stage is right before you look at the second. Extracting a derived table from the middle of a 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 run DROP TABLE employees; when you have finished with it:
The seed selects the roots, and the recursive member joins the table to the rows already produced. The depth column is both the answer and the guard.
Cycles are the failure mode. If A reports to B and B reports to A, nothing in the query stops, and it runs until it hits the depth limit:
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 answers ERROR 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

The CTE is evaluated first, so the rows it names are decided before the statement changes anything.

Troubleshooting

See also