A CTE (Common Table Expression) is syntax that uses a WITH clause to define a “temporary table” at the beginning of a SQL statement.
It dramatically improves SQL readability by splitting up complex subqueries and allowing the code to be read naturally from top to bottom.
WITH cte_name AS ( -- 1. Define a temporary table named cte_name here SELECT id, name FROM users WHERE status = 'active' ) SELECT * -- 2. Use the defined cte_name in the main query FROM cte_name;
Define a CTE named active_users, a temporary table containing only users whose status is 'active' from the users table.
Then retrieve user_id and name from active_users in the main query.
| user_id | name | status |
|---|---|---|
| 1 | Alice | active |
| 2 | Bob | inactive |
| 3 | Carol | active |
| user_id | name |
|---|---|
| 1 | Alice |
| 3 | Carol |
-- 1. Define a CTE (temporary table) named active_users with WITH WITH active_users AS ( SELECT user_id, name FROM users WHERE status = 'active' -- keep only active rows ) -- 2. Main query: treat the CTE like a regular table SELECT user_id, name FROM active_users; -- name the CTE in the FROM clause /* Execution order (fundamentals): 1. CTE definition: filter rows with status='active' from users, creating the virtual table active_users in memory 2. Main query: read from active_users and return the required columns */
LEGEND
1. Evaluate the Query Inside the CTE
FROM users WHERE status = 'active'First, the SELECT inside the WITH clause is evaluated. Rows that do not satisfy status = 'active' are removed from the entire users table, leaving only the required data.| user_id | name | status |
|---|---|---|
| 1 | Alice | active |
| 2 | Bob | inactive |
| 3 | Carol | active |
SELECT, but a CTE lets you define a component—a virtual table—at the start of the query as WITH name AS ( query ). This maps the thought process “first carve out only the data needed from the large source table” directly into code.You can define multiple CTEs by separating them with commas. This is especially useful when you need to preprocess several tables—by filtering or aggregating them—before a JOIN.
WITH cte_A AS ( SELECT ... -- first CTE ), cte_B AS ( SELECT ... -- second CTE (connect with a comma; do not repeat WITH) ) SELECT * FROM cte_A JOIN cte_B ON ... ;
WITH again.Create a CTE named premium_users that selects users with rank = 'premium' from users.
Next, create a CTE named high_orders that selects orders with amount >= 5000 from orders.
Finally, INNER JOIN the two CTEs on user_id in the main query and return the user name (name) and order amount (amount).
| user_id | name | rank |
|---|---|---|
| 1 | Alice | premium |
| 2 | Bob | normal |
| 3 | Carol | premium |
| order_id | user_id | amount |
|---|---|---|
| 1 | 1 | 8000 |
| 2 | 2 | 12000 |
| 3 | 3 | 3000 |
| 4 | 3 | 9000 |
| name | amount |
|---|---|
| Alice | 8000 |
| Carol | 9000 |
-- First CTE: select premium users WITH premium_users AS ( SELECT user_id, name FROM users WHERE rank = 'premium' ), -- connect CTEs with a comma (essential) high_orders AS ( -- Second CTE: select high-value orders of 5000 or more SELECT user_id, amount FROM orders WHERE amount >= 5000 ) -- Main query: join the two CTEs SELECT p.name, h.amount FROM premium_users p JOIN high_orders h ON p.user_id = h.user_id; /* Execution order: 1. Evaluate premium_users → hold it temporarily in memory 2. Evaluate high_orders → hold it temporarily in memory 3. JOIN in the main query → match on user_id */
LEGEND
1. Build the First CTE
CTE: premium_usersFirst, select the specified users from users to build the first temporary table, premium_users. Unneeded data is removed.| user_id | name | rank |
|---|---|---|
| 1 | Alice | premium |
| 2 | Bob | normal |
| 3 | Carol | premium |
WITH cte_A AS (...), WITH cte_B AS (...) is a syntax error. Write WITH only once at the beginning of the query and connect subsequent CTEs with commas.One of the most useful CTE patterns is joining results aggregated with GROUP BY to master data such as user information.
Forcing aggregation (GROUP BY) and joining (JOIN) into one SELECT can make the code complicated. Extracting the aggregation into a CTE keeps the statement clean.
Create a CTE named user_sales that calculates each user’s total order amount (total_amount) from orders.
Then JOIN users and user_sales in the main query and return the user name (name) and total order amount.
Sort the output by total order amount in descending order, highest first.
| user_id | name |
|---|---|
| 1 | Alice |
| 2 | Bob |
| 3 | Carol |
| order_id | user_id | amount |
|---|---|---|
| 1 | 1 | 5000 |
| 2 | 1 | 3000 |
| 3 | 2 | 10000 |
| name | total_amount |
|---|---|
| Bob | 10000 |
| Alice | 8000 |
-- 1. CTE: aggregate total order amount per user from orders WITH user_sales AS ( SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id ) -- 2. Main query: join the aggregate CTE to users to retrieve names SELECT u.name, s.total_amount FROM users u JOIN user_sales s ON u.user_id = s.user_id ORDER BY s.total_amount DESC; /* Execution order: 1. Evaluate user_sales → aggregate orders by user_id 2. JOIN users and user_sales → match on user_id 3. ORDER BY → sort total amount descending */
LEGEND
1. Group Aggregation in the CTE
GROUP BY user_id → SUM(amount)Group the orders table by user—a vertical compression—and calculate total amounts. The aggregate result becomes a temporary table named user_sales.| user_id | total_amount |
|---|---|
| 1 | 8000 |
| 2 | 10000 |
FROM users u JOIN (SELECT user_id, SUM(amount)...) s ON .... Extracting it to a top-level CTE instead makes what is being aggregated immediately clear from the name user_sales and dramatically improves the code’s structure and readability.When multiple CTEs are defined in a comma-separated chain, a later CTE can reference a CTE defined before it.
WITH cte_1 AS ( SELECT ... -- Step A (for example, remove unnecessary data) ), cte_2 AS ( SELECT ... FROM cte_1 -- Step B (further aggregate Step A’s result) ) SELECT * FROM cte_2; -- final output
Chaining processing in this way lets you read and write SQL like a data pipeline flowing from top to bottom.
Implement the following steps as a chain of CTEs over the orders table.
- CTE
valid_orders: select only orders whose status is not'cancelled'. - CTE
user_totals: aggregate each user’s total amount (total) fromvalid_orders. - Main query: from
user_totals, retrieveuser_idandtotalfor users whose total is at least10000.
| order_id | user_id | amount | status |
|---|---|---|---|
| 1 | 1 | 15000 | completed |
| 2 | 1 | 5000 | cancelled |
| 3 | 2 | 8000 | completed |
| 4 | 2 | 3000 | completed |
| user_id | total |
|---|---|
| 1 | 15000 |
| 2 | 11000 |
-- 1. First CTE: exclude canceled orders WITH valid_orders AS ( SELECT user_id, amount FROM orders WHERE status != 'cancelled' ), user_totals AS ( -- 2. Second CTE: aggregate by referencing valid_orders SELECT user_id, SUM(amount) AS total FROM valid_orders -- reference the previously defined CTE GROUP BY user_id ) -- 3. Main query: filter by referencing the second CTE, user_totals SELECT user_id, total FROM user_totals WHERE total >= 10000; -- only users whose total is at least 10000 /* Execution order: 1. Exclude cancelled from orders → build valid_orders 2. Group by user_id → build user_totals 3. Filter on the total threshold → keep qualifying rows */
LEGEND
1. Step One: Remove Unneeded Data
CTE: valid_ordersFirst, exclude canceled orders. Performing this preprocessing in the first CTE keeps the subsequent aggregation logic simple.| order_id | user_id | amount | status |
|---|---|---|---|
| 1 | 1 | 15000 | completed |
| 2 | 1 | 5000 | cancelled |
| 3 | 2 | 8000 | completed |
| 4 | 2 | 3000 | completed |
SELECT * FROM (SELECT user_id, SUM(amount) FROM (SELECT ... WHERE status != 'cancelled') GROUP BY ...) WHERE total >= 10000;. The matryoshka-like nesting makes it extremely difficult to read.A very common production web-application task—for example, on an account page—is retrieving each user’s latest record, such as their newest order or login history entry.
Several techniques can solve this problem. One of the most general and understandable is to calculate each user’s latest timestamp with MAX in a CTE, then JOIN that result to the source table to identify the corresponding row.
Retrieve each user’s latest order—the row with the greatest ordered_at—from the orders table.
First, in a CTE named latest_dates, aggregate the greatest ordered_at for each user and name it max_date.
Then JOIN the original orders table to the CTE on both user_id and ordered_at = max_date. Return order_id, user_id, amount, and ordered_at for each latest order.
| order_id | user_id | amount | ordered_at |
|---|---|---|---|
| 1 | 1 | 3000 | 2024-01-10 |
| 2 | 1 | 5000 | 2024-02-15 |
| 3 | 2 | 10000 | 2024-01-20 |
| 4 | 2 | 8000 | 2024-03-01 |
| user_id | order_id | amount | ordered_at |
|---|---|---|---|
| 1 | 2 | 5000 | 2024-02-15 |
| 2 | 4 | 8000 | 2024-03-01 |
-- 1. CTE: identify each user’s newest order date WITH latest_dates AS ( SELECT user_id, MAX(ordered_at) AS max_date -- use MAX to obtain the latest date FROM orders GROUP BY user_id ) -- 2. Main query: join the source table to the CTE and keep only the latest rows SELECT o.user_id, o.order_id, o.amount, o.ordered_at FROM orders o JOIN latest_dates ld ON o.user_id = ld.user_id -- composite JOIN: same user_id and order date equal to max_date AND o.ordered_at = ld.max_date ORDER BY o.user_id; /* Execution order: 1. Build latest_dates 2. Join orders (o) to latest_dates (ld) */
LEGEND
1. Calculate Latest Dates in the CTE
GROUP BY user_id → MAX(ordered_at)First, calculate only the newest order date (MAX) for each user and build a temporary table named latest_dates. Other columns such as amount are not available yet.| user_id | max_date |
|---|---|
| 1 | 2024-02-15 |
| 2 | 2024-03-01 |
SELECT user_id, order_id, MAX(ordered_at) inside the CTE because order_id is not aggregated. A two-stage process is therefore required: calculate only the latest date in the CTE, then use that date to look up the source row with a JOIN.ROW_NUMBER() window function, covered in Q8.