SQLab Hub
Lesson 30 · CTE
Lesson 30 · Advanced

SQL CTEs: The WITH Clause, With Examples

A CTE (common table expression) is a named query you define with WITH at the top of a statement and then use like a table. It lets you build a complicated query in readable steps: first work out an intermediate result, give it a name, then query that name.

WITH · Multiple · Chained CTEs Recursive CTEs ▶ Runnable Playground examples 🧪 6 practice exercises 📝 40-question quiz ⏱ ~35 min

What Is a CTE in SQL?

Suppose you want the employees who earn more than their department's average salary. That takes two steps: work out each department's average, then compare every employee with it. A CTE writes the first step as a named result, dept_avg, that the main query can join to:

SQLite · runs in the Playground
WITH dept_avg AS (
  SELECT department, ROUND(AVG(salary)) AS avg_salary
  FROM employees
  GROUP BY department
)
SELECT e.first_name, e.last_name, e.department, e.salary, d.avg_salary
FROM employees e
JOIN dept_avg d ON d.department = e.department
WHERE e.salary > d.avg_salary
ORDER BY e.department, e.salary DESC
LIMIT 8;

▶ Run this query in the SQL Playground

Employees paid above their department average
first_namelast_namedepartmentsalaryavg_salary
NancyThompsonCustomer Success203999134140
MariaGarciaCustomer Success191120134140
BarbaraBakerCustomer Success188280134140
MelissaThomasCustomer Success178391134140
DorothyCampbellCustomer Success165118134140
CharlesAllenCustomer Success162901134140
SarahRamirezCustomer Success152154134140
EdwardRamirezCustomer Success137737134140
✓ 8 rows · real output from the Playground database

Read it top to bottom: first the departments and their averages, then the employees above them. The CTE exists only while this one statement runs. Nothing is stored in the database, and the name dept_avg cannot be used by the next query.

WITH Clause Syntax

SQL · syntax
WITH cte_name AS (
  SELECT ...            -- any SELECT
)
SELECT ...
FROM cte_name;        -- use it like a table
PartMeaning
WITHStarts the list of CTEs, before the main query
cte_name AS ( ... )Gives the query in brackets a name
cte_name (col1, col2) AS ( ... )Optional column names for the CTE
A commaSeparates several CTEs in the same WITH
The main queryComes after the last CTE and can use any of them

CTEs are standard SQL. They work in PostgreSQL, SQL Server, Oracle, SQLite and MySQL from version 8.0 (MySQL 5.7 does not support them).

Multiple and Chained CTEs

One WITH can define several CTEs, separated by commas, and a later CTE can use an earlier one. That turns a long query into a pipeline of small steps. Here the first CTE totals revenue per month, the second adds the previous month with LAG, and the main query calculates the change:

SQLite · runs in the Playground
WITH monthly AS (
  SELECT strftime('%Y-%m', order_date) AS month,
         ROUND(SUM(total), 2) AS revenue
  FROM orders
  WHERE status NOT IN ('cancelled', 'refunded')
    AND order_date >= '2024-01-01'
  GROUP BY month
),
with_previous AS (
  SELECT month, revenue,
         LAG(revenue) OVER (ORDER BY month) AS previous_revenue
  FROM monthly
)
SELECT month, revenue, previous_revenue,
       ROUND(revenue - previous_revenue, 2) AS change
FROM with_previous
ORDER BY month;

▶ Run this query in the SQL Playground

Monthly revenue in 2024 and the change from the month before
monthrevenueprevious_revenuechange
2024-0114705.26NULLNULL
2024-0214578.0414705.26-127.22
2024-0314978.414578.04400.36
2024-0417959.2514978.42980.85
2024-056307.0617959.25-11652.19
2024-061485.46307.06-4821.66
2024-077981.921485.46496.52
2024-0823737.277981.9215755.35
2024-094319.4623737.27-19417.81
2024-103926.434319.46-393.03
✓ 12 rows · first 10 shown · real output from the Playground database

Each step can be tested on its own: replace the main query with SELECT * FROM monthly and you see just the first step. That is one of the most practical reasons to write CTEs.

Using one CTE twice

A CTE can be referenced more than once. This compares each product category's revenue with the overall average across categories, reading category_sales twice:

SQLite · runs in the Playground
WITH category_sales AS (
  SELECT p.category, ROUND(SUM(oi.subtotal), 2) AS revenue
  FROM order_items oi
  JOIN products p ON p.id = oi.product_id
  GROUP BY p.category
)
SELECT category, revenue,
       CASE WHEN revenue > (SELECT AVG(revenue) FROM category_sales)
            THEN 'Above average' ELSE 'Below average' END AS compared
FROM category_sales
ORDER BY revenue DESC;

▶ Run this query in the SQL Playground

Category revenue compared with the average category
categoryrevenuecompared
Office Supplies47482.77Above average
Toys44782.9Above average
Automotive43055.93Above average
Food & Beverage42452.58Above average
Books40552.7Above average
Health & Beauty33218.16Below average
Home & Garden31079.84Below average
Sports & Outdoors30319.13Below average
Clothing28015.78Below average
Electronics25811.19Below average
✓ 10 rows · real output from the Playground database

CTE vs Subquery vs Temp Table vs View

The same first example can be written with a subquery in FROM (a derived table). It gives the same rows; the CTE just puts the step first and names it:

SQL · the same query with a subquery
SELECT e.first_name, e.salary, d.avg_salary
FROM employees e
JOIN (SELECT department, ROUND(AVG(salary)) AS avg_salary
      FROM employees
      GROUP BY department) d ON d.department = e.department
WHERE e.salary > d.avg_salary;
CTESubqueryTemporary tableView
Lives forOne statementOne statementThe session (until dropped)Permanently, until dropped
StoredNoNoYes, as dataOnly the query text
Reuse by nameWithin the statementNo, repeat itAcross statementsAcross statements and users
Can be indexedNoNoYesNo (materialized views can)
Best forReadable multi-step queriesA small one-off stepLarge intermediate results reused many timesA query many people need

A CTE is mainly about readability. Whether it is faster than a subquery depends on the database: PostgreSQL 12+ and SQL Server usually inline a CTE into the main query, so the speed is the same. See subqueries and views.

Common CTE Patterns

Top N per group

Window functions cannot go in WHERE, so the usual way to keep the top rows per group is to number them in a CTE and filter in the main query. The two most expensive products in each category:

SQLite · runs in the Playground
WITH ranked AS (
  SELECT category, name, price,
         ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS rn
  FROM products
)
SELECT category, name, price
FROM ranked
WHERE rn <= 2
ORDER BY category, rn
LIMIT 10;

▶ Run this query in the SQL Playground

Two most expensive products per category
categorynameprice
Automotive2000A Jump Starter Pack1948.86
AutomotiveBack Seat Organizer1804.31
BooksThe Manager's Path1979.28
BooksClean Code (Martin)1891.09
ClothingWinter Parka Jacket1695.08
ClothingCasual Midi Dress1573.83
ElectronicsWebcam HD 1080p1606.25
ElectronicsLaptop Pro 15"1280.74
Food & BeverageSingle Origin Coffee 500g1991.75
Food & BeverageJapanese Green Tea 50 bags1937.51
✓ 10 rows · real output from the Playground database

Second-highest salary

A classic interview question. DENSE_RANK gives equal salaries the same rank, so rank 2 is the second-highest distinct salary:

SQLite · runs in the Playground
WITH ranked AS (
  SELECT first_name, last_name, salary,
         DENSE_RANK() OVER (ORDER BY salary DESC) AS salary_rank
  FROM employees
)
SELECT first_name, last_name, salary
FROM ranked
WHERE salary_rank = 2;

▶ Run this query in the SQL Playground

Employee with the second-highest salary
first_namelast_namesalary
NancyThompson203999
✓ 1 row · real output from the Playground database

Finding duplicates

Several Playground products share a name. A CTE that counts each name makes the duplicates easy to list:

SQLite · runs in the Playground
WITH name_counts AS (
  SELECT name, COUNT(*) AS copies, MIN(id) AS first_id
  FROM products
  GROUP BY name
)
SELECT name, copies, first_id
FROM name_counts
WHERE copies > 1
ORDER BY copies DESC, name
LIMIT 8;

▶ Run this query in the SQL Playground

Product names that appear more than once
namecopiesfirst_id
Artisan Hot Sauce Trio58
Ergonomic Desk Mat XL44
Giant Stuffed Teddy Bear47
Sticky Notes Assorted 18pk416
The Manager's Path445
4K Dual Dash Camera33
Heavy Duty Stapler 70-Sheet344
Hydrating Face Moisturizer331
✓ 8 rows · real output from the Playground database

To list every duplicate row except the first of each name, number the rows with ROW_NUMBER() OVER (PARTITION BY name ORDER BY id) in the CTE and keep rn > 1. The same CTE in front of a DELETE removes them, in databases that allow a CTE before DELETE (SQL Server, PostgreSQL; SQLite and MySQL need a subquery instead).

Cohorts: when did customers first buy?

SQLite · runs in the Playground
WITH first_orders AS (
  SELECT user_id, MIN(order_date) AS first_order_date
  FROM orders
  GROUP BY user_id
)
SELECT strftime('%Y', first_order_date) AS cohort_year,
       COUNT(*) AS new_customers
FROM first_orders
GROUP BY cohort_year
ORDER BY cohort_year;

▶ Run this query in the SQL Playground

Customers by the year of their first order
cohort_yearnew_customers
202252
202336
202426
✓ 3 rows · real output from the Playground database

Retention: who came back the next year?

SQLite · runs in the Playground
WITH buyer_years AS (
  SELECT DISTINCT user_id, CAST(strftime('%Y', order_date) AS INTEGER) AS yr
  FROM orders
)
SELECT a.yr AS year,
       COUNT(*) AS buyers,
       COUNT(b.user_id) AS bought_again_next_year
FROM buyer_years a
LEFT JOIN buyer_years b ON b.user_id = a.user_id AND b.yr = a.yr + 1
WHERE a.yr < 2024
GROUP BY a.yr
ORDER BY a.yr;

▶ Run this query in the SQL Playground

Buyers per year and how many bought again the following year
yearbuyersbought_again_next_year
20225215
20235119
✓ 2 rows · real output from the Playground database

15 of the 52 customers who bought in 2022 bought again in 2023. The CTE is joined to itself, which is something a subquery would have to repeat in full.

Recursive CTEs

A recursive CTE refers to itself. It is how SQL walks a hierarchy of unknown depth, such as an org chart or a category tree, or generates a series of numbers or dates. It has two parts joined by UNION ALL:

PartRole
Anchor queryThe starting rows, for example the employees with no manager
Recursive queryJoins back to the CTE to add the next level, for example everyone who reports to the rows found so far
Stop conditionThe recursion ends when the recursive query adds no new rows, or when a WHERE such as level < 10 stops it
SQL · PostgreSQL, MySQL 8, SQLite (SQL Server and Oracle omit RECURSIVE)
WITH RECURSIVE org AS (
  -- anchor: the top of the org chart
  SELECT id, first_name, manager_id, 1 AS level
  FROM employees
  WHERE manager_id IS NULL

  UNION ALL

  -- recursive part: people who report to someone already in org
  SELECT e.id, e.first_name, e.manager_id, o.level + 1
  FROM employees e
  JOIN org o ON e.manager_id = o.id
  WHERE o.level < 10            -- safety stop
)
SELECT level, COUNT(*) AS employees
FROM org
GROUP BY level
ORDER BY level;
SQL · generate the numbers 1 to 10
WITH RECURSIVE numbers(n) AS (
  SELECT 1
  UNION ALL
  SELECT n + 1 FROM numbers WHERE n < 10
)
SELECT n FROM numbers;
Recursive queries do not run in the SQLabHub Playground. The Playground rejects WITH RECURSIVE so that a missing stop condition cannot loop forever, which is why these two examples have no result table. Run them in a local SQLite, PostgreSQL or MySQL 8 database. In the Playground data the org chart has only two levels, so a self join shows it completely.

Rules to remember: the anchor and recursive parts must return the same number of columns with compatible types; use UNION ALL (the standard requires it for the recursive part in most databases); and always make sure the recursion ends. SQL Server stops after 100 levels by default (OPTION (MAXRECURSION n) changes that); PostgreSQL has no default limit.

Common CTE Mistakes

Six errors and how to avoid them.

1. Using a CTE in a second statement

❌ dept_avg no longer exists
SQL
WITH dept_avg AS (SELECT department, AVG(salary) AS a
                  FROM employees GROUP BY department)
SELECT * FROM dept_avg;

SELECT * FROM dept_avg;  -- error: no such table
✅ Use it in the same statement
SQL
WITH dept_avg AS (SELECT department, AVG(salary) AS a
                  FROM employees GROUP BY department)
SELECT * FROM dept_avg
WHERE a > 120000;

A CTE lives for one statement. For something reusable across queries, create a view or a temporary table.

2. Repeating WITH for the second CTE

❌ Syntax error
SQL
WITH a AS (SELECT ...)
WITH b AS (SELECT ...)
SELECT ...
✅ One WITH, commas between
SQL
WITH a AS (SELECT ...),
     b AS (SELECT ...)
SELECT ...

WITH appears once; each further CTE follows after a comma.

3. A comma after the last CTE

❌ Syntax error
SQL
WITH a AS (SELECT ...),
     b AS (SELECT ...),
SELECT * FROM b;
✅ No comma before the main query
SQL
WITH a AS (SELECT ...),
     b AS (SELECT ...)
SELECT * FROM b;

The database expects another CTE after a comma, so it reports an error at SELECT.

4. A recursive CTE that never stops

❌ Runs until a limit or forever
SQL
WITH RECURSIVE n(x) AS (
  SELECT 1
  UNION ALL
  SELECT x + 1 FROM n
)
SELECT * FROM n;
✅ Has a stop condition
SQL
WITH RECURSIVE n(x) AS (
  SELECT 1
  UNION ALL
  SELECT x + 1 FROM n WHERE x < 100
)
SELECT * FROM n;

Each step adds a new row, so without the WHERE the recursion never runs out of rows.

5. Anchor and recursive parts with different columns

❌ Column count mismatch
SQL
SELECT id, first_name FROM employees WHERE manager_id IS NULL
UNION ALL
SELECT e.id FROM employees e JOIN org o ON e.manager_id = o.id
✅ Same columns, same order
SQL
SELECT id, first_name FROM employees WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.first_name FROM employees e JOIN org o ON e.manager_id = o.id

Both halves of the UNION ALL must return the same number of columns with compatible types.

6. Expecting a CTE to make a query faster

❌ Same plan as the subquery
SQL
-- "I rewrote it as a CTE, so it must be faster"
✅ Measure, then choose
SQL
-- Compare EXPLAIN for both versions;
-- use a temp table with an index for a large
-- intermediate result that is reused often

CTEs are mainly for readability. Many databases inline them, so performance only changes if the plan changes.

CTE Cheat Sheet

TaskPattern
One CTEWITH a AS (SELECT ...) SELECT ... FROM a;
Several CTEsWITH a AS (...), b AS (...) SELECT ...
Chained stepsWITH a AS (...), b AS (SELECT ... FROM a) SELECT ... FROM b;
Top N per groupROW_NUMBER() OVER (PARTITION BY g ORDER BY x DESC) in the CTE, WHERE rn <= N outside
Nth highestDENSE_RANK() OVER (ORDER BY x DESC) in the CTE, WHERE rnk = N outside
RecursiveWITH RECURSIVE r AS (anchor UNION ALL step) SELECT ... FROM r;

Practice CTE Queries

6 exercises on the real Playground tables.

Write each query yourself first. Every solution has been run against the Playground database.

  1. 1Easy Using a CTE, total the order value per customer and list the five customers who spent the most. Table: orders

    Show solution
    SQLite · runs in the Playground
    WITH spend AS (
      SELECT user_id, ROUND(SUM(total), 2) AS total_spent
      FROM orders
      GROUP BY user_id
    )
    SELECT user_id, total_spent
    FROM spend
    ORDER BY total_spent DESC
    LIMIT 5;

    ▶ Run this query in the SQL Playground

    The CTE does the grouping; the main query only sorts and limits.

  2. 2Easy Using a CTE, find the average price per category and show only the categories whose average is above 1,000. Table: products

    Show solution
    SQLite · runs in the Playground
    WITH category_avg AS (
      SELECT category, ROUND(AVG(price), 2) AS avg_price
      FROM products
      GROUP BY category
    )
    SELECT category, avg_price
    FROM category_avg
    WHERE avg_price > 1000
    ORDER BY avg_price DESC;

    ▶ Run this query in the SQL Playground

    Filtering the CTE in WHERE does the same job as HAVING in a single query.

  3. 3Medium Show the highest-paid employee in each department. Table: employees

    Show solution
    SQLite · runs in the Playground
    WITH ranked AS (
      SELECT department, first_name, last_name, salary,
             ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rn
      FROM employees
    )
    SELECT department, first_name, last_name, salary
    FROM ranked
    WHERE rn = 1
    ORDER BY salary DESC;

    ▶ Run this query in the SQL Playground

    Number the employees within each department, then keep number 1.

  4. 4Medium With two CTEs, find the average rating per product and then the number of products in each category whose average is 4 or more. Table: reviews, products

    Show solution
    SQLite · runs in the Playground
    WITH product_rating AS (
      SELECT product_id, AVG(rating) AS avg_rating
      FROM reviews
      GROUP BY product_id
    ),
    good_products AS (
      SELECT p.category
      FROM products p
      JOIN product_rating r ON r.product_id = p.id
      WHERE r.avg_rating >= 4
    )
    SELECT category, COUNT(*) AS well_rated_products
    FROM good_products
    GROUP BY category
    ORDER BY well_rated_products DESC, category;

    ▶ Run this query in the SQL Playground

    The second CTE uses the first; the main query counts what is left.

  5. 5Medium Find the third-highest order total. Table: orders

    Show solution
    SQLite · runs in the Playground
    WITH ranked AS (
      SELECT total, DENSE_RANK() OVER (ORDER BY total DESC) AS rnk
      FROM orders
    )
    SELECT DISTINCT total
    FROM ranked
    WHERE rnk = 3;

    ▶ Run this query in the SQL Playground

    DENSE_RANK makes rank 3 the third distinct value even if totals repeat.

  6. 6Hard List the customers whose first order was in 2024, with the date of that order. Show the first 8 by date. Table: orders, users

    Show solution
    SQLite · runs in the Playground
    WITH first_orders AS (
      SELECT user_id, MIN(order_date) AS first_order_date
      FROM orders
      GROUP BY user_id
    )
    SELECT u.first_name, u.last_name, f.first_order_date
    FROM first_orders f
    JOIN users u ON u.id = f.user_id
    WHERE f.first_order_date >= '2024-01-01'
    ORDER BY f.first_order_date, u.id
    LIMIT 8;

    ▶ Run this query in the SQL Playground

    The CTE finds each customer's first order across all years; filtering on 2024 afterwards keeps only the customers who were new that year.

📝 Quiz — CTE

Test your understanding · 0/40 answered

Question 1 of 40

What does CTE stand for?

Question 2 of 40

Which keyword introduces a CTE?

Question 3 of 40

How long does a CTE exist?

Question 4 of 40

Can a query define more than one CTE?

Question 5 of 40

What is the main benefit of a CTE over a deeply nested subquery?

Question 6 of 40

What is a recursive CTE?

Question 7 of 40

What are the two parts of a recursive CTE?

Question 8 of 40

What happens if a recursive CTE has no stop condition?

Question 9 of 40

What is the key difference between a CTE and a temporary table?

Question 10 of 40

What is the key difference between a CTE and a subquery?

Question 11 of 40

Can a CTE contain aggregate functions such as SUM and AVG?

Question 12 of 40

Can a CTE be combined with window functions such as ROW_NUMBER?

Question 13 of 40

What does it mean to chain CTEs?

Question 14 of 40

How do you find the second-highest salary with a CTE?

Question 15 of 40

How do you find duplicate rows with a CTE?

Question 16 of 40

What is a common real use of a recursive CTE?

Question 17 of 40

Are CTEs part of standard SQL?

Question 18 of 40

Is a CTE always computed once and cached?

Question 19 of 40

How do analysts commonly use CTEs?

Question 20 of 40

Are CTEs common in SQL interviews?

Question 21 of 40

What is the anchor query in a recursive CTE for?

Question 22 of 40

What joins the anchor and the recursive query?

Question 23 of 40

Can a CTE refer to a CTE defined earlier in the same WITH?

Question 24 of 40

What is a common column mistake in recursive CTEs?

Question 25 of 40

Why is UNION ALL used in a recursive CTE?

Question 26 of 40

How do you generate the numbers 1 to 100 with a recursive CTE?

Question 27 of 40

How does a recursive CTE differ from a self join for hierarchies?

Question 28 of 40

How do you find the top 2 products per category with a CTE?

Question 29 of 40

What can EXPLAIN show about a non-recursive CTE?

Question 30 of 40

Can a CTE come before INSERT, UPDATE or DELETE?

Question 31 of 40

Why prefer a CTE over a temp table for a one-off report?

Question 32 of 40

Why prefer a temp table over a CTE in a multi-step ETL job?

Question 33 of 40

How would you build a cohort analysis with CTEs?

Question 34 of 40

What is a risk of referencing one expensive CTE many times?

Question 35 of 40

How do CTEs relate to views?

Question 36 of 40

What must be true of the columns in the two parts of a recursive CTE?

Question 37 of 40

Can the main query reference the same CTE more than once?

Question 38 of 40

What separates two CTE definitions?

Question 39 of 40

Which of these is NOT a good reason to use a CTE?

Question 40 of 40

How do you stop infinite recursion?

CTE Interview Questions

Q1. What is a CTE and why would you use one?

A named query defined with WITH that exists for one statement. It makes multi-step queries readable, lets you test each step on its own, and lets one intermediate result be referenced several times.

Q2. CTE, subquery or temporary table?

A subquery for a small one-off step; a CTE when the query has several steps or reuses one; a temporary table when a large intermediate result is needed by several statements or needs an index.

Q3. Is a CTE faster than a subquery?

Usually no different. Most modern databases inline non-recursive CTEs into the main query. Some materialize them, which can help or hurt. Compare the plans.

Q4. How do you find the Nth highest value?

WITH r AS (SELECT x, DENSE_RANK() OVER (ORDER BY x DESC) AS rnk FROM t) SELECT DISTINCT x FROM r WHERE rnk = N. DENSE_RANK keeps ties from skipping values.

Q5. Explain how a recursive CTE runs.

The anchor query produces the first rows. The recursive query is run against the rows produced in the previous step, and its output is added. This repeats until a step produces no rows.

Q6. How do you protect a recursive CTE from looping forever?

Add a depth counter and a WHERE limit in the recursive part, use UNION (where allowed) to drop repeated rows in cyclic data, and rely on limits such as SQL Server's MAXRECURSION.

Q7. How do you remove duplicates with a CTE?

Number rows with ROW_NUMBER() OVER (PARTITION BY the duplicate columns ORDER BY id) in a CTE and delete the rows where the number is above 1, in databases that allow a CTE before DELETE; otherwise use the same logic in a subquery.

Frequently Asked Questions

What is a CTE in SQL?

A CTE (common table expression) is a named, temporary result defined with the WITH clause. It exists only for the statement it belongs to and lets you break a complex query into readable, named steps.

What is the WITH clause?

WITH is the keyword that introduces one or more CTEs before the main query. WITH cte_name AS (SELECT ...) SELECT * FROM cte_name; defines a named result and then queries it.

What is a recursive CTE?

A CTE that refers to itself. An anchor query gives the starting rows and a recursive query repeatedly joins back to the CTE until it adds no new rows. It is the standard tool for hierarchies such as org charts and category trees.

What is the difference between a CTE and a subquery?

A CTE is named once before the main query and can be referenced several times, which helps readability. A subquery is written inline and has to be repeated if the same logic is needed again.

What is the difference between a CTE and a temporary table?

A CTE is not stored and exists for one statement. A temporary table is actually created, can be indexed and can be used by several statements in the same session.

Can CTEs improve performance?

Not by themselves. Their main benefit is readability. Whether a CTE is computed once or inlined depends on the database, so check an expensive CTE with EXPLAIN; a temporary table may be faster for large reused results.

How do data analysts use CTEs?

To structure multi-step analysis: an aggregate in one CTE, then ranking, filtering or joining it in the next. Cohort analysis, retention and month-over-month revenue are typical examples.

Are CTEs important in SQL interviews?

Yes. CTE versus subquery and temporary table, recursive CTEs, and problems such as the second-highest salary or the top N rows per category are common interview questions.