Common Table Expressions (CTEs)

Name each step of a query with WITH, reuse it, and generate series with WITH RECURSIVE

Advanced 25 minCTEWITHWITH RECURSIVE

What Is a CTE in SQL?

A common table expression (CTE) is a named, temporary result set that you define with WITH at the start of a statement and then query like a table. It exists only while that one statement runs.

Named steps — Instead of nesting subqueries inside each other, you give each step a name and read the query from top to bottom.
Several CTEs, one WITH — Separate CTEs with commas. Each one can read the CTEs listed before it, so a long query becomes a short pipeline of steps.
Reuse — Refer to the same CTE as often as you need within the statement, for example in FROM and again in a WHERE subquery, without writing its query twice.
One statement only — A CTE is not saved in the database. The next statement can't see it; if several statements need the same result, use a temporary table or a view.
Recursive CTEs — With WITH RECURSIVE, a CTE can refer to itself. That is how you generate number and date series or walk a hierarchy such as an org chart.

Knowledge Canvas

How CTEs Work

Named temporary result sets

  • WITH name AS (query) defines a named result set
  • Several CTEs: one WITH, a comma between them
  • Each CTE can read the CTEs defined before it
  • A CTE lasts for one statement only

How to Read a CTE Chain

Each step can use the ones above it

1
WITH step1 AS (...) reads base tables
2
step2 AS (...) can read step1
3
step3 AS (...) can read step1 and step2
4
The final SELECT can read any of them; the database still picks the actual execution order

Scope and Lifetime at a Glance

Subquery, CTE, temp table and view

Subquery: inline, no name
CTE: named at the top of the statement
Subquery: write it twice to use it twice
CTE: reference it as often as needed in one statement
CTE: gone when the statement ends
Temp table: real rows for the session, can be indexed
Temp table: stores rows
View: stores only the query, re-run on every use

Recursive CTE in Three Parts

Anchor, recursive member, stop condition

  • Anchor: runs once and gives the starting rows
  • Recursive member: runs on the previous step's rows
  • Stops when a step returns no rows
  • UNION ALL stacks the rows of every step
WITH RECURSIVE numbers AS (
  SELECT 1 AS n   -- anchor
  UNION ALL
  SELECT n + 1    -- recursive member
  FROM numbers
  WHERE n < 5     -- stop condition
)
SELECT n FROM numbers;  -- 1 to 5

CTE Gotchas

Where CTE queries go wrong

  • The next statement can't see the CTE
  • ORDER BY inside a CTE doesn't sort the final result
  • Recursive stop condition: n < 5 gives 1–5, n <= 5 gives 1–6
  • Default recursion limits: SQL Server 100, MySQL 1,000; PostgreSQL and SQLite none

CTE Patterns

Common real-world structures

  • Compute a value once (an average) and compare every row to it
  • Aggregate each child table in its own CTE before joining
  • Recursive calendar + LEFT JOIN to show empty months as 0
  • Number rows with ROW_NUMBER in a CTE, then filter in the outer query

Best Practices

Writing clean CTEs

  • Name CTEs after what they contain: customer_totals, not step2
  • Give every computed column an alias
  • Don't reuse a table's name for a CTE
  • Test a step by ending the query with SELECT * FROM step_name

Syntax

Syntax Template
1-- One CTE
2WITH cte_name AS (
3 SELECT
4 column1,
5 column2
6 FROM table_name
7)
8SELECT *
9FROM cte_name;
10
11-- Several CTEs, one WITH
12WITH step1 AS (
13 SELECT ...
14),
15-- step2 can read step1
16step2 AS (
17 SELECT ...
18 FROM step1
19)
20SELECT *
21FROM step2;
22
23-- Optional column list
24WITH customer_totals (customer_id, total_spent) AS (
25 SELECT
26 customer_id,
27 SUM(total)
28 FROM orders
29 GROUP BY
30 customer_id
31)
32SELECT *
33FROM customer_totals;
34
35-- Recursive CTE: numbers 1 to 5
36WITH RECURSIVE numbers AS (
37 -- anchor member: runs once
38 SELECT
39 1 AS n
40 UNION ALL
41 -- recursive member: runs on
42 -- the previous step's rows
43 SELECT
44 n + 1
45 FROM numbers
46 -- stop condition
47 WHERE n < 5
48)
49SELECT n
50FROM numbers;
WITHStarts the list of CTEs. Write it once, before the first CTE.
name AS ( query )Defines one CTE. Pick a name that is not also a table name, and give every computed column an alias (SUM(total) AS total_spent) or name the columns in the optional column list.
,Separates one CTE from the next. No comma after the last CTE.
(col1, col2)Optional column list. Names the CTE's columns in order, replacing the names from the query.
RECURSIVENeeded when a CTE refers to itself in PostgreSQL and MySQL. Optional in SQLite; SQL Server and Oracle leave it out.
Anchor memberThe first SELECT of a recursive CTE. Runs once and produces the starting rows.
UNION ALLAdds the rows of each recursive step to the result.
Recursive memberThe SELECT that reads the CTE itself. Runs on the previous step's rows; its WHERE is the stop condition.

Sample Data

orders
12 rows
idcustomer_idorder_datestatustotal
5001C10012024-01-15delivered59.98
5002C10022024-02-08delivered449
5003C10012024-02-20shipped12.5
5004C10032024-03-05delivered89.99
5005C10062024-03-15pending245
5006C10052024-03-22delivered159
5007C10042024-04-02shipped45.5
5008C10092024-04-12cancelled79
5009C10122024-04-22delivered425
5010C10072024-05-05pending18.99
5011C10082024-05-18delivered134
5012C10102024-06-08cancelled65.5
customers
12 rows
idname
C1001Aarav Sharma
C1002Sara Chen
C1003James Wilson
C1004Maria Garcia
C1005Yuki Tanaka
C1006Priya Patel
C1007Alex Johnson
C1008Chen Wei
C1009Emma Brown
C1010Omar Hassan
C1011Lena Muller
C1012Ravi Kumar
products
10 rows
idname
P101Wireless Mouse
P102Office Desk
P103LED Desk Lamp
P104USB-C Cable
P105Bluetooth Speaker
P106Office Chair
P107Coffee Maker
P108Notebook Set
P109Wireless Headphones
P110Standing Desk
order_items
16 rows
order_idproduct_idquantity
5001P1012
5001P1041
5002P1092
5002P1021
5003P1081
5004P1071
5005P1061
5005P1021
5006P1091
5007P1031
5008P1051
5009P1101
5010P1011
5011P1051
5011P1031
5012P1071
reviews
12 rows
product_idrating
P1015
P1014
P1013
P1025
P1024
P1034
P1045
P1055
P1054
P1073
P1085
P1095

Reuse One CTE Instead of Repeating a Subquery

Find the customers whose orders add up to more than the average customer's.

SQL
1WITH customer_totals AS (
2 SELECT
3 customer_id,
4 SUM(total) AS total_spent
5 FROM orders
6 GROUP BY
7 customer_id
8)
9SELECT
10 c.name,
11 ct.total_spent
12FROM customer_totals ct
13JOIN customers c
14 ON c.id = ct.customer_id
15WHERE ct.total_spent > (
16 SELECT AVG(total_spent)
17 FROM customer_totals
18)
19ORDER BY
20 ct.total_spent DESC;
customer_totals adds up each customer's orders, whatever their status: 11 of the 12 customers have orders (Lena Muller has none), and their totals average 162.13. The final query uses that one CTE twice, in FROM to list the customers and in the WHERE subquery to compute the average. Three customers are above it: Sara Chen (449), Ravi Kumar (425) and Priya Patel (245).

Without a CTE you would write the same GROUP BY query twice, once as a derived table in FROM and again inside the AVG subquery. That version returns the same 3 rows, but every later change has to be made in both places. You could also move the average into a second CTE, avg_spend AS (SELECT AVG(total_spent) AS avg_total FROM customer_totals), because each CTE can read the ones listed before it.
Output
3 rows
nametotal_spent
Sara Chen449
Ravi Kumar425
Priya Patel245

Multiple CTEs: Aggregate Each Table Before Joining

For each product that has both sales and reviews, show the units sold, the average rating and the number of reviews.

SQL
1WITH units AS (
2 SELECT
3 product_id,
4 SUM(quantity) AS units_sold
5 FROM order_items
6 GROUP BY
7 product_id
8),
9ratings AS (
10 SELECT
11 product_id,
12 AVG(rating) AS avg_rating,
13 COUNT(*) AS review_count
14 FROM reviews
15 GROUP BY
16 product_id
17)
18SELECT
19 p.name,
20 u.units_sold,
21 r.avg_rating,
22 r.review_count
23FROM products p
24JOIN units u
25 ON u.product_id = p.id
26JOIN ratings r
27 ON r.product_id = p.id
28ORDER BY
29 u.units_sold DESC,
30 p.name;
units totals the quantity ordered per product, whatever the order's status (as in the first example), and ratings summarizes the reviews per product. Each CTE has exactly one row per product, so joining them to products can't duplicate anything: Wireless Mouse sold 3 units (2 in order 5001, 1 in order 5010) and has 3 reviews averaging 4.

If you instead join products, order_items and reviews first and then GROUP BY, the numbers inflate. Wireless Mouse has 2 order lines and 3 reviews, so the join produces 2 × 3 = 6 rows: SUM(quantity) reports 9 units instead of 3, and the review count 6 instead of 3. Office Desk and Bluetooth Speaker jump from 2 units to 4. (The average rating survives only because every review is repeated equally often.) Aggregating each one-to-many table in its own CTE before the join avoids this.

The result has 8 rows because the inner join to ratings drops Office Chair and Standing Desk, which have no reviews. Use LEFT JOIN ratings to keep all 10 products; those two then show NULL in the rating columns.
Output
8 rows
nameunits_soldavg_ratingreview_count
Wireless Headphones351
Wireless Mouse343
Bluetooth Speaker24.52
Coffee Maker231
LED Desk Lamp241
Office Desk24.52
Notebook Set151
USB-C Cable151

CTE vs Subquery vs Temp Table vs View

All four let you name or reuse the result of a query. They differ in how long the result lives and who can use it:

Subquery — Written inline where it is used, with no name. To use the same result twice, you write it twice.
CTE — Named at the top of one statement and usable as often as you like inside it. Gone when the statement ends. Recursive queries are usually written as CTEs; to reuse one in several statements, put it inside a view or save its rows to a temp table.
Temporary table — CREATE TEMP TABLE ... AS SELECT ... stores real rows that last until your session ends, so several statements can use them, and you can index them.
View — CREATE VIEW saves the query, not its rows, permanently in the database. The query runs again every time the view is used.

Are CTEs Faster Than Subqueries?

Not as a rule. A CTE changes how a query is written; how fast it runs depends on how the database executes it:

PostgreSQL 12 and later merge a CTE that is used once into the main query, as long as it isn't recursive and has no side effects such as changing data, so it runs just like a subquery. Before version 12, PostgreSQL always computed every CTE on its own first. Writing AS MATERIALIZED or AS NOT MATERIALIZED after the CTE's name overrides the choice (PostgreSQL 12 release notes).
PostgreSQL computes a CTE that is referenced several times only once and keeps its rows. The catch: a WHERE in the main query can't then shrink what the CTE reads, so the CTE may process far more rows than the final result needs.
SQL Server does not store a CTE's result: each reference runs the CTE's query again (Microsoft SQL Server blog). If a heavy CTE is used many times, a temporary table can be faster.

When speed matters, compare the execution plans (EXPLAIN in most databases) instead of assuming.

How a Recursive CTE Works

A recursive CTE reads its own output. It has two queries joined by UNION ALL, which stacks the rows of two queries into one result (UNION and UNION ALL):

The anchor member produces the starting rows.
The recursive member selects from the CTE itself.

Take the number series from the syntax block, WITH RECURSIVE numbers AS (SELECT 1 AS n UNION ALL SELECT n + 1 FROM numbers WHERE n < 5) SELECT n FROM numbers. It runs like this:

1The anchor SELECT 1 AS n runs once and returns one row: 1.
2The recursive member runs on the rows from the previous step only. From 1 it produces 2.
3It runs again on 2 to produce 3, then on 3 to produce 4, then on 4 to produce 5.
4On 5, WHERE n < 5 is false, so the step returns no rows and the recursion ends.
5UNION ALL stacks the rows of every step: 1, 2, 3, 4, 5.

Despite the name, the database runs this as a loop, one step at a time, until a step returns nothing (PostgreSQL docs). The stop condition is what ends it, and it tests the row a step starts from: n < 5 stops at 5, while n <= 5 lets 5 through and adds a sixth row.

In PostgreSQL, MySQL and SQLite you can write UNION instead of UNION ALL: rows already in the result are then discarded, which also ends a loop that keeps producing the same values. SQL Server and Oracle accept only UNION ALL here.

Common uses:

Series — the numbers 1 to N, or every day or month in a range, so a report has no gaps (next example).
Hierarchies — employees with a manager_id or categories with a parent_id: the anchor selects the top rows, and the recursive member joins the table to the rows of the previous level.

Recursive CTE: Fill In Months With No Orders

Show the number of orders and the revenue for every month of 2024, including months with no orders. Leave out cancelled orders.

SQL
1WITH RECURSIVE months AS (
2 SELECT
3 '2024-01-01' AS month_start
4 UNION ALL
5 SELECT
6 DATE(month_start, '+1 month')
7 FROM months
8 WHERE month_start < '2024-12-01'
9)
10SELECT
11 STRFTIME('%Y-%m', m.month_start) AS month,
12 COUNT(o.id) AS order_count,
13 COALESCE(SUM(o.total), 0) AS revenue
14FROM months m
15LEFT JOIN orders o
16 ON o.order_date >= m.month_start
17 AND o.order_date < DATE(m.month_start, '+1 month')
18 AND o.status <> 'cancelled'
19GROUP BY
20 m.month_start
21ORDER BY
22 m.month_start;
The months CTE builds the calendar: the anchor returns '2024-01-01', and each step adds a month with DATE(month_start, '+1 month') until month_start reaches '2024-12-01', giving 12 rows. The LEFT JOIN keeps every month and matches the orders placed from the first day of that month up to, but not including, the first day of the next.

The status filter is the third condition in ON, not a WHERE. In ON it only decides which orders match a month, so June, whose only order was cancelled, still appears with 0. In WHERE, o.status <> 'cancelled' would run after the join and also remove every empty month: their o.status is NULL, and a comparison with NULL is never true, so only 5 rows, January to May, would be left. Whenever a LEFT JOIN has to keep the rows with no match, filter the right-hand table in ON.

A plain GROUP BY on orders has the same gap: it returns only the months that have orders that aren't cancelled, January to May, so June to December would be missing from a chart instead of showing 0. Two details make empty months read 0: COUNT(o.id) counts only matched orders (COUNT(*) would count the empty month's own row and report 1), and COALESCE(SUM(o.total), 0) turns the NULL sum of an empty month into 0 (see NULLs in aggregates). Scroll the output to see the rest of the year.

SQLite stores dates as text, so a plain string works as the anchor. In other databases, start from a real date, CAST('2024-01-01' AS DATE), and use their date step: PostgreSQL (month_start + INTERVAL '1 month')::date, MySQL DATE_ADD(month_start, INTERVAL 1 MONTH), SQL Server DATEADD(month, 1, month_start). Always step from the 1st of a month: in SQLite, DATE('2024-01-31', '+1 month') returns '2024-03-02'.
Output
12 rows
monthorder_countrevenue
2024-01159.98
2024-022461.5
2024-033493.99
2024-042470.5
2024-052152.99
2024-0600
2024-0700
2024-0800
2024-0900
2024-1000
2024-1100
2024-1200

CTEs in PostgreSQL, MySQL, SQL Server and Oracle

The WITH syntax on this page is standard SQL and works almost unchanged everywhere. The differences are mostly about recursion:

PostgreSQL (since 8.4) — WITH RECURSIVE is required for a recursive CTE, and a CTE can refer to one listed after it only when RECURSIVE is present. No recursion limit. generate_series() builds number and date series without recursion. PostgreSQL docs
MySQL (8.0 and later; 5.7 has no CTEs) — WITH RECURSIVE is required. Recursion stops with an error after 1,000 levels by default (cte_max_recursion_depth). The recursive member can't use aggregate or window functions, GROUP BY, ORDER BY or DISTINCT, and a recursive CTE's column types come from the anchor, so widen them there with CAST. MySQL docs
SQL Server — No RECURSIVE keyword: a CTE that refers to itself is recursive. A CTE can only refer to CTEs defined before it. The statement before WITH must end with a semicolon, which is why you often see ;WITH. Recursion stops with an error after 100 levels unless you add OPTION (MAXRECURSION n) to the outer query (0 means no limit, the maximum is 32,767). ORDER BY inside a CTE is rejected unless TOP or OFFSET ... FETCH is also used. SQL Server docs
SQLite (since 3.8.3; SQLRide runs 3.49) — Accepts WITH RECURSIVE but doesn't require the RECURSIVE keyword. No recursion limit. The core library, and so SQLRide, has no generate_series() (the sqlite3 command-line shell includes it as an extension), so a recursive CTE is the portable way to build a series. SQLite docs
Oracle — Calls CTEs "subquery factoring". Recursive WITH works since 11g Release 2, without a RECURSIVE keyword, but a recursive CTE needs a column list after its name. The optional CYCLE clause handles loops in the data; without it, a cycle raises an error. Oracle docs

PostgreSQL, SQL Server and SQLite also accept WITH in front of INSERT, UPDATE and DELETE, not only SELECT. MySQL accepts it in front of UPDATE and DELETE, and before the SELECT in INSERT ... SELECT.

Common Mistakes

❌

Using a CTE in a second statement

SQL
1WITH customer_totals AS (
2 SELECT
3 customer_id,
4 SUM(total) AS total_spent
5 FROM orders
6 GROUP BY
7 customer_id
8)
9SELECT COUNT(*) AS customers
10FROM customer_totals;
11
12SELECT *
13FROM customer_totals;

A CTE belongs to the statement that defines it. Run alone, the first statement returns 11. The second fails with no such table: customer_totals, because the CTE disappeared when the first statement ended.

Do all the work that needs the CTE inside the one statement that defines it. If several statements need the same result, store it in a temporary table or a view.

❌

Writing WITH before every CTE

SQL
1WITH customer_totals AS (
2 SELECT
3 customer_id,
4 SUM(total) AS total_spent
5 FROM orders
6 GROUP BY
7 customer_id
8)
9WITH big_spenders AS (
10 SELECT *
11 FROM customer_totals
12 WHERE total_spent > 200
13)
14SELECT *
15FROM big_spenders;

One WITH introduces all the CTEs of a statement. A second WITH at the same level is a syntax error: SQLite reports near "WITH": syntax error, and the MySQL and SQL Server docs forbid it too.

Write WITH once and put a comma between CTEs: WITH customer_totals AS (...), big_spenders AS (...) SELECT .... Leave no comma after the last CTE, or SQLite reports near "SELECT": syntax error.

❌

Sorting inside the CTE and expecting a sorted result

SQL
1WITH ranked_orders AS (
2 SELECT
3 customer_id,
4 total
5 FROM orders
6 ORDER BY
7 total DESC
8)
9SELECT
10 c.name,
11 r.total
12FROM customers c
13JOIN ranked_orders r
14 ON r.customer_id = c.id;

An ORDER BY inside a CTE doesn't control the order of the final result. Here SQLite returns the 12 rows in order-ID order (Aarav Sharma 59.98, Sara Chen 449, Aarav Sharma 12.5, …), not by total. Without an outer ORDER BY, a database may return rows in any order it finds fastest.

Sort in the outer query: ORDER BY r.total DESC after the join puts Sara Chen (449) first and Aarav Sharma (12.5) last. Inside a non-recursive CTE, ORDER BY is only useful together with LIMIT (TOP in SQL Server), to keep the top N rows.

❌

A recursive CTE that stops one step late, or never

SQL
1WITH RECURSIVE numbers AS (
2 SELECT
3 1 AS n
4 UNION ALL
5 SELECT
6 n + 1
7 FROM numbers
8 WHERE n <= 5
9)
10SELECT n
11FROM numbers;

The condition is checked on the row a step starts from. When n is 5, 5 <= 5 is still true, so the CTE adds one more row and returns 6 rows, 1 to 6. The same slip in the calendar example, month_start <= '2024-12-01', returns 13 months ending at 2025-01-01. With no WHERE at all, the CTE never stops: in SQLRide that freezes the browser tab (SQL Server and MySQL stop it with an error; see the database notes above).

Use WHERE n < 5, one step before the last value you want. While testing a new recursive CTE in SQLite, put a LIMIT on the recursive SELECT itself (SELECT n + 1 FROM numbers WHERE n < 5 LIMIT 100): SQLite stops adding rows once the CTE has 100, whatever the outer query does. A LIMIT on the outer query is not enough: if the outer query sorts or groups, as the calendar query does, the CTE must finish first and never does. In any database, a depth counter also works: 1 AS depth in the anchor, depth + 1 in the recursive member, and WHERE depth < 100.

Key Concepts

1A CTE is a named result set defined with WITH and used like a table
2It exists only for the one statement that defines it
3Separate several CTEs with commas — each can use the ones before it
4Reference the same CTE more than once in that statement
5WITH RECURSIVE = anchor + UNION ALL + recursive member with a stop condition
6Speed depends on the engine: PostgreSQL 12+ merges single-use CTEs into the query; SQL Server re-runs each reference

Pro Tip

Use CTEs for clarity, not speed: each step gets a name you can test on its own. To check a step, end the query with SELECT * FROM step_name for a moment, confirm the rows, then build the next step on top of it.

When to Use

Multi-step reports where each step builds on the last; totaling child tables before joining them; comparing every row with an average computed once; filling in missing dates so a chart has no gaps; keeping the top row per group with ROW_NUMBER; walking org charts and category trees.

Challenge

Solve the problem below

Using a recursive CTE, list every month from 2024-04 to 2024-09 with the number of delivered orders (status 'delivered') placed in it, including months with none. Return two columns: month, formatted like 2024-04, and delivered_orders.

Your Query