Skip to content

Intermediate: combining and changing data · Subqueries, CTEs and set operations · topic 29 of 62 · 35 min

Common table expressions (WITH)

Explanation

A common table expression (CTE) names a subquery at the top of a query with WITH name AS (...), so the main query can read it like a table. CTEs turn a tangle of nested subqueries into named steps that read from top to bottom, and one CTE can use the ones before it.

Why name a subquery?

Derived tables work, but they nest inside out: to understand the query you start in the middle and read outwards. With two or three steps, the brackets pile up and the logic hides. A common table expression, or CTE, moves each step to the top of the query and gives it a name. You read the query like a recipe: first this, then this, then the answer.

sql
WITH totals AS (
  SELECT c.name, SUM(oi.qty * oi.price) AS total
  FROM customers AS c
  JOIN orders AS o       ON o.customer_id = c.id
  JOIN order_items AS oi ON oi.order_id = o.id
  WHERE o.status <> 'Cancelled'
  GROUP BY c.id, c.name
)
SELECT name, total
FROM totals
WHERE total > (SELECT AVG(total) FROM totals)
ORDER BY total DESC;
-- Ravi Kumar | 6497
-- Asha Verma | 4678
Step 1 (totals) makes one row per customer. The main query uses it twice: once for the rows, once for the average (3461).

The shape is WITH name AS (query), followed by the main statement. The CTE exists only while this one statement runs; it is not saved anywhere. Using a CTE twice, as above, is something a derived table cannot do without copying the whole subquery.

Several CTEs, each building on the last

Separate the CTEs with commas, and write WITH only once. A later CTE may read an earlier one.

sql
WITH dept_stats AS (
  SELECT dept_id, COUNT(*) AS headcount, AVG(salary) AS avg_salary
  FROM employees
  WHERE dept_id IS NOT NULL
  GROUP BY dept_id
),
company AS (
  SELECT AVG(salary) AS avg_all FROM employees
)
SELECT d.name, s.headcount,
       ROUND(s.avg_salary) AS avg_salary,
       ROUND(s.avg_salary - c.avg_all) AS vs_company
FROM dept_stats AS s
JOIN departments AS d ON d.id = s.dept_id
CROSS JOIN company AS c
ORDER BY vs_company DESC;
-- Engineering | 4 | 140000 |  40000
-- Finance     | 2 |  86500 | -13500
-- Sales       | 3 |  80667 | -19333
-- HR          | 1 |  80000 | -20000
company has one row, so CROSS JOIN simply attaches the company average to every department row.
Think of it like this: A CTE is like the rough work at the side of a maths exam paper. You compute the subtotals on the side, label them, and then write the final answer using those labels. The rough work is not part of the answer and is thrown away afterwards.

Monthly revenue, then filter

sql
WITH monthly AS (
  SELECT substr(o.order_date, 1, 7) AS month,
         SUM(oi.qty * oi.price) AS revenue
  FROM orders AS o
  JOIN order_items AS oi ON oi.order_id = o.id
  WHERE o.status <> 'Cancelled'
  GROUP BY month
)
SELECT month, revenue FROM monthly WHERE revenue >= 3000 ORDER BY month;
-- 2025-01 | 3779
-- 2025-03 | 7167
substr(date, 1, 7) keeps the "YYYY-MM" part. You could use HAVING here too, but the CTE keeps "compute" and "filter" as two clear steps.

CTE, subquery or view?

ToolLives forGood for
Subquery / derived tableOne spot in one queryA small step used once
CTE (WITH)One statementSeveral named steps, or a step used more than once
View (module 10)Saved in the databaseA query many reports reuse, under a name everyone can see

Does a CTE make queries faster?

Not by itself; it is mainly about readability. How it runs depends on the engine. Materialising means computing the CTE once and storing the result in a temporary table; inlining means pasting it into the main query as if it were a subquery, so the planner can optimise everything together. PostgreSQL before version 12 always materialised CTEs, which sometimes made them slower. Since version 12 it inlines a CTE used once, materialises one used more than once, and lets you choose with AS MATERIALIZED or AS NOT MATERIALIZED. SQLite (3.35+) accepts the same hints. SQL Server treats a CTE like an inline view and does not store it. MySQL supports CTEs from version 8.0. If speed matters, check the query plan (module 10).

Common mistakes

  • Writing WITH before every CTE. Write WITH once, then name AS (...), name2 AS (...), separated by commas.
  • A comma after the last CTE, before the main SELECT: a syntax error.
  • Expecting the CTE to exist in the next statement. It lives for one statement only; use a view or a table to keep it.
  • A semicolon between the CTE and its SELECT, which ends the statement early. The main query follows the closing bracket directly.

Need help?

Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.

Try:

Watch the lesson

Common table expressions (WITH) · 1/6

Common table expressions (WITH)

Nested subqueries are read from the inside out, which gets hard. A CTE gives each step a name at the top.

Prefer a video by a teacher? Find free videos on “sql cte with clause common table expression tutorial” on YouTube ↗

In real life: A monthly business review query

Analysts at startups and large companies alike write their monthly reports as a chain of CTEs: clean the orders, total them per month, compare each month with the previous one, and finally pick what goes on the slide. Each step has a name, so a reviewer can run one CTE at a time and check its numbers. Tools such as dbt are built around this step-by-step style.

sql
WITH delivered AS (
  SELECT * FROM orders WHERE status = 'Delivered'
),
per_customer AS (
  SELECT customer_id, COUNT(*) AS orders FROM delivered GROUP BY customer_id
)
SELECT c.name, p.orders
FROM per_customer AS p
JOIN customers AS c ON c.id = p.customer_id;
Asha Verma 2 and Ravi Kumar 2 delivered orders.

Practice (0/3 done)

Task 1 · Code · SQL

Write a two-step CTE report. Step 1 (a CTE named totals): each customer's name and total spent (sum of qty * price), ignoring Cancelled orders. Step 2: return name and total for customers whose total is above the average of all those totals, highest total first.

Task 2 · Code · SQL

Use a CTE named monthly that computes revenue (sum of qty * price, ignoring Cancelled orders) per month, where month is substr(order_date, 1, 7). Then return month and revenue for the months with revenue of at least 3000, in month order.

Task 3 · Code · SQL

Use two CTEs. dept_stats: for employees who have a department, dept_id, headcount and avg_salary per department. company: the average salary of all employees. Return the department name, headcount, avg_salary rounded to a whole number (ROUND(x)), and vs_company (department average minus company average, rounded to a whole number), largest vs_company first.

Quick quiz

Question 1

1.How long does a CTE exist?

Question 2

2.How do you define two CTEs in one query?

Question 3

3.Which is a real advantage of a CTE over a derived table?

Question 4

4.In PostgreSQL 12 and later, how is a CTE that is referenced only once usually run?

Question 5

5.You want a named query that many reports and users can reuse across sessions. What fits best?

Read more (free)

Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.

How long does a CTE exist?

Answer: Only while the one statement that defines it runs

It is part of one statement. A view is the saved version.

How do you define two CTEs in one query?

Answer: WITH a AS (...), b AS (...) SELECT ...

WITH once, CTEs separated by commas, no comma before the main query.

Which is a real advantage of a CTE over a derived table?

Answer: It can be referenced more than once by name, and steps read from top to bottom

Readability and reuse within the statement; speed depends on the engine.

In PostgreSQL 12 and later, how is a CTE that is referenced only once usually run?

Answer: Inlined into the main query, unless you write AS MATERIALIZED

PostgreSQL 12 changed the default; earlier versions always materialised.

You want a named query that many reports and users can reuse across sessions. What fits best?

Answer: A view

A view is saved in the database (module 10); a CTE lives for one statement.

What is a CTE?Related

Answer: A named temporary result written with WITH, used by the query after it

WITH name AS (...) defines a temporary named result that exists only for that query.

From Subqueries, CTEs and window functions

A correlated subquery is slow on a table of 10 lakh rows. What is a good first step?Related

Answer: Check the plan, index the linking column, or rewrite it as a join to a grouped derived table

In principle it runs once per outer row; an index or a rewrite helps.

From Correlated subqueries and derived tables

In PostgreSQL, a subquery used with = returns 3 rows. What happens?Related

Answer: An error: more than one row returned by a subquery

PostgreSQL, MySQL and SQL Server raise an error; SQLite quietly uses the first row.

From Scalar subqueries

In SQL Server, a recursive CTE goes deeper than 100 levels. What happens by default?Related

Answer: It stops with an error; OPTION (MAXRECURSION n) changes the limit

SQL Server's default MAXRECURSION is 100.

From Recursive CTEs

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

Next steps

  1. 2 more topics in “Subqueries, CTEs and set operations” before its exam: Recursive CTEs, UNION, INTERSECT and EXCEPT.
  2. Module 6 exam: subqueries, CTEs and set operations · 14 questions · 25 min, without help. 6 topics of this module still to finish first.
  3. Then the next module, Window functions. 62 topics left in the course before the final exam.

← Previous: Correlated subqueries and derived tables