MySQLCommon Table Expressions (CTE)

Common Table Expressions (CTEs) in MySQL

A Common Table Expression (CTE) is a named, temporary result set defined within a WITH clause at the start of a query. CTEs make complex queries readable by letting you name and build up intermediate steps, similar to giving names to variables in a programming language.

CTEs were added in MySQL 8.0. If you are on MySQL 5.7 or earlier, use derived tables (subqueries in FROM) as an alternative.

Basic CTE Syntax

SQL
WITH cte_name AS (
  SELECT ...   -- CTE body
)
SELECT *
FROM cte_name;  -- reference the CTE here
Your First CTE

SQL
-- Without CTE: deeply nested subquery
SELECT *
FROM (
  SELECT customer_id, COUNT(*) AS order_count, SUM(total) AS total_spent
  FROM orders
  WHERE status = 'delivered'
  GROUP BY customer_id
) AS stats
WHERE total_spent > 500;

-- With CTE: named step, much more readable
WITH customer_stats AS (
  SELECT customer_id, COUNT(*) AS order_count, SUM(total) AS total_spent
  FROM orders
  WHERE status = 'delivered'
  GROUP BY customer_id
)
SELECT *
FROM customer_stats
WHERE total_spent > 500
ORDER BY total_spent DESC;
Note
CTEs are not materialized by default in MySQL 8.0 — the optimizer may inline them or choose to materialize them based on cost. You cannot create indexes on a CTE, but you can use window functions and aggregations inside them freely.
Multiple CTEs in One Query

You can define multiple CTEs in a single WITH clause, separated by commas. Later CTEs can reference earlier ones, letting you build complex logic step by step.

SQL
WITH
-- Step 1: summarize orders per customer
customer_orders AS (
  SELECT
    customer_id,
    COUNT(*)             AS order_count,
    ROUND(SUM(total), 2) AS total_spent,
    MAX(created_at)      AS last_order_date
  FROM orders
  WHERE status = 'delivered'
  GROUP BY customer_id
),

-- Step 2: calculate overall averages
overall_stats AS (
  SELECT
    ROUND(AVG(total_spent), 2)  AS avg_lifetime_value,
    ROUND(AVG(order_count), 1)  AS avg_orders_per_customer
  FROM customer_orders
),

-- Step 3: classify customers (references customer_orders)
classified AS (
  SELECT
    co.*,
    CASE
      WHEN co.total_spent >= 2 * os.avg_lifetime_value THEN 'VIP'
      WHEN co.total_spent >= os.avg_lifetime_value      THEN 'Regular'
      ELSE                                                   'Low-value'
    END AS segment
  FROM customer_orders AS co
  CROSS JOIN overall_stats AS os
)

-- Final query: join classified customers with their details
SELECT
  c.customer_id,
  c.first_name,
  c.email,
  cl.order_count,
  cl.total_spent,
  cl.last_order_date,
  cl.segment
FROM customers  AS c
JOIN classified AS cl ON c.customer_id = cl.customer_id
ORDER BY cl.total_spent DESC;
CTE vs Subquery vs Temporary Table

Feature

CTE (WITH)

Subquery

Temporary Table

Readability

Excellent — named steps

Poor when nested deeply

Good — named

Reuse in same query

Yes — reference multiple times

No — must repeat

Yes

Indexes

No

No

Yes — can add indexes

Persists beyond query

No

No

Yes — for the session

Recursive?

Yes (WITH RECURSIVE)

No

No

MySQL version

8.0+

All versions

All versions

CTEs Referenced Multiple Times

Unlike subqueries, a CTE can be referenced multiple times in the main query. The optimizer decides whether to materialize it once or re-evaluate each reference.

SQL
WITH monthly_revenue AS (
  SELECT
    DATE_FORMAT(created_at, '%Y-%m') AS month,
    ROUND(SUM(total), 2)             AS revenue
  FROM orders
  WHERE status = 'delivered'
  GROUP BY DATE_FORMAT(created_at, '%Y-%m')
)
-- Reference the same CTE twice in the main query
SELECT
  cur.month,
  cur.revenue,
  prev.revenue       AS prev_month_revenue,
  ROUND(cur.revenue - COALESCE(prev.revenue, 0), 2) AS growth
FROM monthly_revenue AS cur
LEFT JOIN monthly_revenue AS prev
  ON prev.month = DATE_FORMAT(
       DATE_SUB(STR_TO_DATE(CONCAT(cur.month, '-01'), '%Y-%m-%d'), INTERVAL 1 MONTH),
       '%Y-%m')
ORDER BY cur.month;
Recursive CTEs — WITH RECURSIVE

A recursive CTE calls itself and is used for hierarchical or iterative data. It has two parts:

  1. Anchor member — the base case (non-recursive SELECT).
  2. Recursive member — references the CTE itself, adding one level per iteration.

MySQL stops recursion when the recursive member returns no rows, or when the recursion depth limit is hit (default 1000, controlled by cte_max_recursion_depth).

SQL
WITH RECURSIVE cte_name AS (
  -- Anchor: starting rows
  SELECT ...
  UNION ALL
  -- Recursive member: references cte_name
  SELECT ...
  FROM cte_name
  WHERE stop_condition
)
SELECT * FROM cte_name;
Hierarchy Traversal with Recursive CTE

SQL
-- Traverse the employee hierarchy from CEO down to all reports
WITH RECURSIVE org_chart AS (
  -- Anchor: start at the top (employees with no manager)
  SELECT
    employee_id,
    full_name,
    manager_id,
    job_title,
    0 AS depth,
    CAST(full_name AS CHAR(1000)) AS path
  FROM employees
  WHERE manager_id IS NULL

  UNION ALL

  -- Recursive: add direct reports of the previous level
  SELECT
    e.employee_id,
    e.full_name,
    e.manager_id,
    e.job_title,
    oc.depth + 1,
    CONCAT(oc.path, ' > ', e.full_name)
  FROM employees  AS e
  JOIN org_chart  AS oc ON e.manager_id = oc.employee_id
)
SELECT
  REPEAT('  ', depth) AS indent,   -- visual indentation
  full_name,
  job_title,
  depth,
  path
FROM org_chart
ORDER BY path;
Generating a Number Series

SQL
-- Generate integers 1 through 100 using recursive CTE
WITH RECURSIVE numbers AS (
  SELECT 1 AS n
  UNION ALL
  SELECT n + 1 FROM numbers WHERE n < 100
)
SELECT n FROM numbers;

-- Use it to generate a 30-day calendar
WITH RECURSIVE date_series AS (
  SELECT CURDATE() AS dt
  UNION ALL
  SELECT DATE_ADD(dt, INTERVAL 1 DAY)
  FROM date_series
  WHERE dt < DATE_ADD(CURDATE(), INTERVAL 29 DAY)
)
SELECT dt AS calendar_date, DAYNAME(dt) AS day_of_week
FROM date_series
ORDER BY dt;
Fibonacci Sequence Example

SQL
-- Generate the first 15 Fibonacci numbers
WITH RECURSIVE fibonacci AS (
  SELECT 1 AS n, 0 AS a, 1 AS b   -- n = position, a = F(n-1), b = F(n)
  UNION ALL
  SELECT n + 1, b, a + b
  FROM fibonacci
  WHERE n < 15
)
SELECT n AS position, a AS fibonacci_number
FROM fibonacci
ORDER BY n;
Finding All Ancestors of a Node

SQL
-- Given a category ID, find all its parent categories up to the root
WITH RECURSIVE ancestors AS (
  -- Anchor: start at the target category
  SELECT category_id, name, parent_id, 0 AS level
  FROM categories
  WHERE category_id = 42    -- starting node

  UNION ALL

  -- Recursive: join to parent
  SELECT c.category_id, c.name, c.parent_id, a.level + 1
  FROM categories   AS c
  JOIN ancestors    AS a ON c.category_id = a.parent_id
)
SELECT category_id, name, level
FROM ancestors
ORDER BY level DESC;   -- root first
Warning
Always include a termination condition in the recursive member (WHERE n < 1000, WHERE parent_id IS NOT NULL, etc.) to prevent infinite recursion. If the data has cycles (A is parent of B and B is parent of A), MySQL hits the recursion depth limit and throws an error.
Controlling Recursion Depth

SQL
-- Increase the recursion depth limit for deeper hierarchies
SET SESSION cte_max_recursion_depth = 5000;

-- Or set it globally
SET GLOBAL cte_max_recursion_depth = 5000;

-- Check current depth
SELECT @@cte_max_recursion_depth;
CTE for Readable Complex Queries

SQL
-- Full customer cohort analysis: acquisition month, orders, revenue
WITH
cohorts AS (
  SELECT
    customer_id,
    DATE_FORMAT(MIN(created_at), '%Y-%m') AS cohort_month
  FROM orders
  GROUP BY customer_id
),
cohort_revenue AS (
  SELECT
    co.cohort_month,
    DATE_FORMAT(o.created_at, '%Y-%m') AS order_month,
    COUNT(DISTINCT o.customer_id)       AS active_customers,
    ROUND(SUM(o.total), 2)              AS revenue
  FROM orders   AS o
  JOIN cohorts  AS co USING (customer_id)
  WHERE o.status = 'delivered'
  GROUP BY co.cohort_month, order_month
)
SELECT
  cohort_month,
  order_month,
  active_customers,
  revenue
FROM cohort_revenue
ORDER BY cohort_month, order_month;
  • Use CTEs to break complex queries into named, readable steps

  • Chain CTEs with commas inside a single WITH clause

  • Use WITH RECURSIVE for hierarchy traversal and number/date series generation

  • Always include a stopping condition in recursive CTEs to prevent infinite loops

  • Prefer CTEs over deeply nested subqueries — they are easier to read, test, and maintain

  • For very large result sets that are referenced multiple times, a TEMPORARY TABLE with an index may outperform a CTE