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.
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:
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
| first_name | last_name | department | salary | avg_salary |
|---|---|---|---|---|
| Nancy | Thompson | Customer Success | 203999 | 134140 |
| Maria | Garcia | Customer Success | 191120 | 134140 |
| Barbara | Baker | Customer Success | 188280 | 134140 |
| Melissa | Thomas | Customer Success | 178391 | 134140 |
| Dorothy | Campbell | Customer Success | 165118 | 134140 |
| Charles | Allen | Customer Success | 162901 | 134140 |
| Sarah | Ramirez | Customer Success | 152154 | 134140 |
| Edward | Ramirez | Customer Success | 137737 | 134140 |
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
WITH cte_name AS (
SELECT ... -- any SELECT
)
SELECT ...
FROM cte_name; -- use it like a table
| Part | Meaning |
|---|---|
WITH | Starts 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 comma | Separates several CTEs in the same WITH |
| The main query | Comes 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:
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
| month | revenue | previous_revenue | change |
|---|---|---|---|
| 2024-01 | 14705.26 | NULL | NULL |
| 2024-02 | 14578.04 | 14705.26 | -127.22 |
| 2024-03 | 14978.4 | 14578.04 | 400.36 |
| 2024-04 | 17959.25 | 14978.4 | 2980.85 |
| 2024-05 | 6307.06 | 17959.25 | -11652.19 |
| 2024-06 | 1485.4 | 6307.06 | -4821.66 |
| 2024-07 | 7981.92 | 1485.4 | 6496.52 |
| 2024-08 | 23737.27 | 7981.92 | 15755.35 |
| 2024-09 | 4319.46 | 23737.27 | -19417.81 |
| 2024-10 | 3926.43 | 4319.46 | -393.03 |
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:
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 |
|---|---|---|
| Office Supplies | 47482.77 | Above average |
| Toys | 44782.9 | Above average |
| Automotive | 43055.93 | Above average |
| Food & Beverage | 42452.58 | Above average |
| Books | 40552.7 | Above average |
| Health & Beauty | 33218.16 | Below average |
| Home & Garden | 31079.84 | Below average |
| Sports & Outdoors | 30319.13 | Below average |
| Clothing | 28015.78 | Below average |
| Electronics | 25811.19 | Below average |
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:
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;
| CTE | Subquery | Temporary table | View | |
|---|---|---|---|---|
| Lives for | One statement | One statement | The session (until dropped) | Permanently, until dropped |
| Stored | No | No | Yes, as data | Only the query text |
| Reuse by name | Within the statement | No, repeat it | Across statements | Across statements and users |
| Can be indexed | No | No | Yes | No (materialized views can) |
| Best for | Readable multi-step queries | A small one-off step | Large intermediate results reused many times | A 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:
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
| category | name | price |
|---|---|---|
| Automotive | 2000A Jump Starter Pack | 1948.86 |
| Automotive | Back Seat Organizer | 1804.31 |
| Books | The Manager's Path | 1979.28 |
| Books | Clean Code (Martin) | 1891.09 |
| Clothing | Winter Parka Jacket | 1695.08 |
| Clothing | Casual Midi Dress | 1573.83 |
| Electronics | Webcam HD 1080p | 1606.25 |
| Electronics | Laptop Pro 15" | 1280.74 |
| Food & Beverage | Single Origin Coffee 500g | 1991.75 |
| Food & Beverage | Japanese Green Tea 50 bags | 1937.51 |
Second-highest salary
A classic interview question. DENSE_RANK gives equal salaries the same rank, so rank 2 is the second-highest distinct salary:
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
| first_name | last_name | salary |
|---|---|---|
| Nancy | Thompson | 203999 |
Finding duplicates
Several Playground products share a name. A CTE that counts each name makes the duplicates easy to list:
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
| name | copies | first_id |
|---|---|---|
| Artisan Hot Sauce Trio | 5 | 8 |
| Ergonomic Desk Mat XL | 4 | 4 |
| Giant Stuffed Teddy Bear | 4 | 7 |
| Sticky Notes Assorted 18pk | 4 | 16 |
| The Manager's Path | 4 | 45 |
| 4K Dual Dash Camera | 3 | 3 |
| Heavy Duty Stapler 70-Sheet | 3 | 44 |
| Hydrating Face Moisturizer | 3 | 31 |
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?
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
| cohort_year | new_customers |
|---|---|
| 2022 | 52 |
| 2023 | 36 |
| 2024 | 26 |
Retention: who came back the next year?
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
| year | buyers | bought_again_next_year |
|---|---|---|
| 2022 | 52 | 15 |
| 2023 | 51 | 19 |
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:
| Part | Role |
|---|---|
| Anchor query | The starting rows, for example the employees with no manager |
| Recursive query | Joins back to the CTE to add the next level, for example everyone who reports to the rows found so far |
| Stop condition | The recursion ends when the recursive query adds no new rows, or when a WHERE such as level < 10 stops it |
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;
WITH RECURSIVE numbers(n) AS (
SELECT 1
UNION ALL
SELECT n + 1 FROM numbers WHERE n < 10
)
SELECT n FROM numbers;
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
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
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
WITH a AS (SELECT ...)
WITH b AS (SELECT ...)
SELECT ...
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
WITH a AS (SELECT ...),
b AS (SELECT ...),
SELECT * FROM b;
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
WITH RECURSIVE n(x) AS (
SELECT 1
UNION ALL
SELECT x + 1 FROM n
)
SELECT * FROM n;
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
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
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
-- "I rewrote it as a CTE, so it must be faster"
-- 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
| Task | Pattern |
|---|---|
| One CTE | WITH a AS (SELECT ...) SELECT ... FROM a; |
| Several CTEs | WITH a AS (...), b AS (...) SELECT ... |
| Chained steps | WITH a AS (...), b AS (SELECT ... FROM a) SELECT ... FROM b; |
| Top N per group | ROW_NUMBER() OVER (PARTITION BY g ORDER BY x DESC) in the CTE, WHERE rn <= N outside |
| Nth highest | DENSE_RANK() OVER (ORDER BY x DESC) in the CTE, WHERE rnk = N outside |
| Recursive | WITH 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.
-
1Easy Using a CTE, total the order value per customer and list the five customers who spent the most. Table:
ordersShow solution
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.
-
2Easy Using a CTE, find the average price per category and show only the categories whose average is above 1,000. Table:
productsShow solution
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.
-
3Medium Show the highest-paid employee in each department. Table:
employeesShow solution
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.
-
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, productsShow solution
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.
-
5Medium Find the third-highest order total. Table:
ordersShow solution
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.
-
6Hard List the customers whose first order was in 2024, with the date of that order. Show the first 8 by date. Table:
orders, usersShow solution
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
What does CTE stand for?
A common table expression is a named result defined with WITH.
Which keyword introduces a CTE?
WITH comes before the main query and lists one or more CTEs.
How long does a CTE exist?
Nothing is stored; the next statement cannot use it.
Can a query define more than one CTE?
WITH a AS (...), b AS (...) SELECT ...
What is the main benefit of a CTE over a deeply nested subquery?
Readability is the main reason; speed depends on the database.
What is a recursive CTE?
It is the standard way to walk hierarchies such as org charts.
What are the two parts of a recursive CTE?
The anchor gives the starting rows; the recursive part adds the next level each time.
What happens if a recursive CTE has no stop condition?
SQL Server stops at 100 levels by default; PostgreSQL has no default limit.
What is the key difference between a CTE and a temporary table?
Use a temp table for a large intermediate result that several statements need.
What is the key difference between a CTE and a subquery?
The retention example joins a CTE to itself, which a subquery would have to write out twice.
Can a CTE contain aggregate functions such as SUM and AVG?
The first example on this page averages salaries per department in a CTE.
Can a CTE be combined with window functions such as ROW_NUMBER?
Window functions cannot be used in WHERE, so the CTE is the way to filter on them.
What does it mean to chain CTEs?
WITH a AS (...), b AS (SELECT ... FROM a) SELECT ... FROM b.
How do you find the second-highest salary with a CTE?
DENSE_RANK handles equal salaries correctly.
How do you find duplicate rows with a CTE?
Both patterns appear on this page using duplicate product names.
What is a common real use of a recursive CTE?
Recursive CTEs handle hierarchies of any depth.
Are CTEs part of standard SQL?
MySQL before 8.0 is the main exception.
Is a CTE always computed once and cached?
PostgreSQL 12+ lets you choose with MATERIALIZED / NOT MATERIALIZED.
How do analysts commonly use CTEs?
Cohorts, retention and monthly change are typical examples.
Are CTEs common in SQL interviews?
The second-highest salary question is a classic.
What is the anchor query in a recursive CTE for?
The recursive part then builds on these rows.
What joins the anchor and the recursive query?
WITH RECURSIVE r AS (anchor UNION ALL step).
Can a CTE refer to a CTE defined earlier in the same WITH?
Each CTE can use the ones before it.
What is a common column mistake in recursive CTEs?
Both halves of the UNION ALL must line up.
Why is UNION ALL used in a recursive CTE?
PostgreSQL and SQLite also accept UNION, which removes duplicate rows and can stop cycles.
How do you generate the numbers 1 to 100 with a recursive CTE?
The WHERE is the stop condition.
How does a recursive CTE differ from a self join for hierarchies?
For the two-level Playground org chart a self join is enough.
How do you find the top 2 products per category with a CTE?
This exact query runs on this page.
What can EXPLAIN show about a non-recursive CTE?
That is how you check whether a CTE rewrite changed the plan.
Can a CTE come before INSERT, UPDATE or DELETE?
SQLite allows WITH before INSERT, UPDATE and DELETE too, but the CTE cannot be the target of the change.
Why prefer a CTE over a temp table for a one-off report?
For a single statement, the CTE is simpler.
Why prefer a temp table over a CTE in a multi-step ETL job?
A CTE exists for one statement only.
How would you build a cohort analysis with CTEs?
The cohort example on this page uses exactly that first step.
What is a risk of referencing one expensive CTE many times?
Check the plan; a temp table may be better.
How do CTEs relate to views?
Learning CTEs makes views easy to understand.
What must be true of the columns in the two parts of a recursive CTE?
A mismatch is an error.
Can the main query reference the same CTE more than once?
The retention example joins buyer_years to itself.
What separates two CTE definitions?
And no comma after the last one.
Which of these is NOT a good reason to use a CTE?
CTEs do not guarantee better performance.
How do you stop infinite recursion?
For example WHERE level < 10 or WHERE n < 100.
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.