iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

WITH (CTEs)

Common Table Expressions (CTEs) name a temporary result set you can reference in the rest of the query. MySQL 8 supports both non-recursive and recursive CTEs — perfect for hierarchical data and stepping through complex transformations.

WITH, recursive, modularization

EXAMPLE
-- 1) Basic CTE — readable replacement for nested subqueries
WITH recent_orders AS (
    SELECT customer_id, SUM(total_cents) AS spent_cents, COUNT(*) AS n
    FROM orders
    WHERE created_at >= NOW() - INTERVAL 30 DAY
      AND status     = 'paid'
    GROUP BY customer_id
)
SELECT c.id, c.name, ro.spent_cents, ro.n
FROM customers c
JOIN recent_orders ro ON ro.customer_id = c.id
WHERE ro.spent_cents > 100000
ORDER BY ro.spent_cents DESC;

-- 2) Multiple CTEs in one query
WITH
  recent_orders AS (
    SELECT customer_id, SUM(total_cents) AS spent
    FROM orders
    WHERE created_at >= NOW() - INTERVAL 30 DAY
    GROUP BY customer_id
  ),
  loyalty AS (
    SELECT customer_id, COUNT(*) AS years
    FROM customer_anniversaries
    GROUP BY customer_id
  )
SELECT c.name, r.spent, COALESCE(l.years, 0) AS years
FROM customers c
LEFT JOIN recent_orders r ON r.customer_id = c.id
LEFT JOIN loyalty l       ON l.customer_id = c.id
ORDER BY r.spent DESC NULLS LAST
LIMIT 50;

-- 3) Recursive CTE — walk a tree (org chart, comment threads, BOM)
CREATE TABLE employees (
    id        INT PRIMARY KEY,
    name      VARCHAR(100),
    manager_id INT NULL,
    salary_cents BIGINT
);

-- Find everyone in the reporting chain under employee 1
WITH RECURSIVE org AS (
    SELECT id, name, manager_id, 0 AS depth
    FROM employees
    WHERE id = 1                                  -- anchor: the starting node

    UNION ALL

    SELECT e.id, e.name, e.manager_id, o.depth + 1
    FROM employees e
    JOIN org o ON e.manager_id = o.id              -- recursive step
)
SELECT id, name, depth FROM org ORDER BY depth, name;

-- 4) Recursive CTE — generate a date series (no calendar table needed)
WITH RECURSIVE days AS (
    SELECT DATE('2025-01-01') AS d
    UNION ALL
    SELECT d + INTERVAL 1 DAY FROM days WHERE d < '2025-12-31'
)
SELECT d FROM days;

-- MySQL caps recursion at @@cte_max_recursion_depth (default 1000).
-- SET SESSION cte_max_recursion_depth = 5000;     -- when you need more

-- 5) Stepwise transformations — each CTE refines the previous
WITH
  raw AS (
    SELECT user_id, JSON_EXTRACT(payload, '$.event') AS event,
                 created_at
    FROM analytics_events
    WHERE created_at >= NOW() - INTERVAL 7 DAY
  ),
  per_user AS (
    SELECT user_id,
                 SUM(CASE WHEN event = '"signup"' THEN 1 ELSE 0 END)   AS signups,
                 SUM(CASE WHEN event = '"purchase"' THEN 1 ELSE 0 END) AS purchases
    FROM raw
    GROUP BY user_id
  ),
  funnel AS (
    SELECT signups, purchases, purchases / NULLIF(signups, 0) AS rate
    FROM per_user
    WHERE signups > 0
  )
SELECT AVG(rate) AS avg_conversion FROM funnel;

-- 6) CTE vs subquery — when each wins
-- CTE wins for:
--   • Reading top-to-bottom
--   • Reusing the same subset multiple times in one query
--   • Recursive structures (only CTE can express them)
--   • Stepwise debugging (run just the WITH ... SELECT * FROM step;)
-- Subquery wins for:
--   • Single tiny use, especially correlated
--   • Pre-MySQL 8.0

-- 7) UPDATE / DELETE with a CTE (MySQL 8.0.1+)
WITH dupes AS (
    SELECT id,
                 ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn
    FROM users
)
DELETE u FROM users u
JOIN dupes d ON d.id = u.id AND d.rn > 1;

-- 8) Materialization — MySQL does NOT have an INLINE/MATERIALIZE hint
-- The optimizer may evaluate a CTE once or inline it per reference.
-- For very heavy CTEs reused N times, consider a temp table:
CREATE TEMPORARY TABLE t_recent AS
    SELECT customer_id, SUM(total_cents) AS spent
    FROM orders WHERE created_at >= NOW() - INTERVAL 30 DAY
    GROUP BY customer_id;
CREATE INDEX ix_t_recent ON t_recent (customer_id);
-- ... use t_recent multiple times ...
DROP TEMPORARY TABLE t_recent;

-- 9) Avoiding accidental recursion
-- A CTE name that appears in its own definition outside RECURSIVE is an error.
-- Always write WITH RECURSIVE when the body references the CTE.

-- 10) Recursive UNION rules
--   • UNION ALL: efficient, may produce duplicates
--   • UNION:     dedupes per step, more expensive
--   • The recursive arm must reference the CTE exactly once
--   • No aggregates, GROUP BY, or DISTINCT in the recursive arm

-- 11) Performance tips
--   • Index columns the recursive step joins on
--   • Keep the anchor selective; broad anchors expand badly
--   • Add explicit LIMIT or depth guard for safety
--   • EXPLAIN ANALYZE  to see how the optimizer treats your CTE

-- 12) Common bugs
--   • WITH RECURSIVE without the RECURSIVE keyword → error
--   • cte_max_recursion_depth hit → 'Recursive query aborted after 1000 iterations'
--   • Naming a CTE the same as a real table → confusion (parser prefers the CTE)
--   • Trying to UPDATE a CTE directly → CTEs are read-only; UPDATE the base table joined to the CTE
--   • Comma between CTE and main query (e.g. WITH x AS (...), SELECT ...) → no comma before SELECT
--   • Expecting MATERIALIZE — MySQL inlines or materializes at its discretion

Why it matters

CTEs are the cleanest way to express a multi-step query — each step gets a name, the order reads top to bottom, and you can pop into the editor and SELECT * FROM step; to debug. For genuinely hierarchical data, the recursive form replaces messy self-joins entirely.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
WITH recent AS (
    SELECT * FROM orders WHERE created_at >= NOW() - INTERVAL 7 DAY
)
SELECT user_id, SUM(total) AS spent
FROM recent
GROUP BY user_id;
Try it Yourself »

Discussion

Loading…