Basic CTE (WITH Clause) — Replace a Subquery with a CTE for Better Readability
A CTE (Common Table Expression) is a temporary named query defined with WITH name AS (...). Extracting logic into a CTE instead of nesting a subquery makes the SQL readable from top to bottom.
WITH cte_name AS ( -- Write a SELECT statement here (think of it as a temporary table) SELECT col1, SUM(col2) AS total FROM some_table GROUP BY col1 ) -- Write the main SELECT after the CTE definition SELECT * FROM cte_name WHERE total > 10000;
CTEs can be used with SELECT, INSERT, UPDATE, and DELETE. This quiz covers SELECT only.
Using the orders table below, write a CTE query for an API that calculates the total sales, order count, and average order value for each month.
Return only months whose average order value is greater than 40,000.
| order_id | order_month | amount |
|---|---|---|
| 1 | 2024-01 | 30000 |
| 2 | 2024-01 | 45000 |
| 3 | 2024-01 | 20000 |
| 4 | 2024-02 | 55000 |
| 5 | 2024-02 | 60000 |
| 6 | 2024-03 | 80000 |
| 7 | 2024-03 | 15000 |
| 8 | 2024-03 | 25000 |
| order_month | total_sales | order_count | avg_order |
|---|---|---|---|
| 2024-02 | 115000 | 2 | 57500 |
-- Define a CTE: WITH lets you treat a subquery like a temporary table WITH monthly_summary AS ( -- ① Build an intermediate table by aggregating orders by month SELECT -- SELECT: specify the columns to retrieve order_month, -- Key column used for monthly aggregation SUM(amount) AS total_sales, -- SUM: calculate monthly sales; AS assigns an alias COUNT(*) AS order_count, -- COUNT(*): count all rows = number of orders AVG(amount) AS avg_order -- AVG: calculate the average monthly order value FROM orders -- FROM: specify the source table GROUP BY order_month -- GROUP BY: group rows from the same month ) -- ② Use the CTE result like a table in FROM SELECT -- SELECT: specify the final output columns order_month, total_sales, order_count, ROUND(avg_order, 0) AS avg_order -- ROUND(value, digits): round decimals (0 means an integer) FROM monthly_summary -- FROM: specify the CTE defined above WHERE avg_order > 40000 -- WHERE: keep only matching rows (greater than 40000) ORDER BY order_month; -- ORDER BY: sort months in ascending order /* Logical execution order: 1. CTE FROM orders → Read every row from the table 2. CTE GROUP BY order_month → Group by month and calculate SUM/COUNT/AVG 3. Outer FROM monthly_summary → Reference the CTE result as a virtual table 4. Outer WHERE avg_order > 40000 → Apply the filter 5. Outer ORDER BY order_month → Sort in ascending order */
LEGEND
① FROM
FROM ordersRead all 8 rows from orders. This is the input to the monthly_summary CTE.| order_id | order_month | amount |
|---|---|---|
| 1 | 2024-01 | 30,000 |
| 2 | 2024-01 | 45,000 |
| 3 | 2024-01 | 20,000 |
| 4 | 2024-02 | 55,000 |
| 5 | 2024-02 | 60,000 |
| 6 | 2024-03 | 80,000 |
| 7 | 2024-03 | 15,000 |
| 8 | 2024-03 | 25,000 |
HAVING avg(amount) > 40000 normally produces the same result, but a CTE separates the aggregation logic from the filtering logic, making conditions easier to change.WITH cte AS (...); SELECT ... is a syntax error. Everything from WITH through the end of SELECT is one SQL statement.Chained CTEs — Calculate User Purchase Ranks in Separate Steps
You can define multiple comma-separated CTEs after WITH, and each later CTE can reference an earlier one. This expresses a SQL pipeline in which “step ② uses the result of step ①.”
WITH step1 AS ( SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id ), -- step2 can reference step1 (define consecutive CTEs separated by commas) step2 AS ( SELECT *, NTILE(4) OVER (ORDER BY total DESC) AS quartile FROM step1 -- Reference the preceding CTE like a table ) SELECT * FROM step2;
Using the users and orders tables below, build an API query that calculates each user's total purchases and ranks users from highest to lowest into quartiles with NTILE.
quartile=1 is the highest rank. Also join user_name from the users table into the final output.
| user_id | user_name |
|---|---|
| U01 | Alice |
| U02 | Bob |
| U03 | Carol |
| U04 | Dave |
| U05 | Eve |
| U06 | Frank |
| U07 | Grace |
| U08 | Hank |
| order_id | user_id | amount |
|---|---|---|
| 1 | U01 | 120000 |
| 2 | U01 | 50000 |
| 3 | U02 | 30000 |
| 4 | U03 | 200000 |
| 5 | U04 | 75000 |
| 6 | U05 | 10000 |
| 7 | U06 | 95000 |
| 8 | U07 | 40000 |
| 9 | U08 | 160000 |
| 10 | U06 | 20000 |
| user_id | user_name | total_amount | quartile |
|---|---|---|---|
| U03 | Carol | 200000 | 1 |
| U01 | Alice | 170000 | 1 |
| U08 | Hank | 160000 | 2 |
| U06 | Frank | 115000 | 2 |
| U04 | Dave | 75000 | 3 |
| U07 | Grace | 40000 | 3 |
| U02 | Bob | 30000 | 4 |
| U05 | Eve | 10000 | 4 |
WITH user_totals AS ( -- ① Sum orders by user (intermediate aggregation) SELECT -- SELECT: specify the columns to retrieve user_id, SUM(amount) AS total_amount -- SUM: add multiple orders; AS assigns an alias FROM orders -- FROM: source table GROUP BY user_id -- GROUP BY: group at user level ), user_ranked AS ( -- ② Use user_totals and NTILE to assign quartiles (reference the previous CTE) SELECT user_id, total_amount, NTILE(4) OVER ( -- NTILE(4): divide all rows into four groups and assign bucket numbers ORDER BY total_amount DESC -- ORDER BY ... DESC: sort purchase totals from highest to lowest ) AS quartile FROM user_totals -- FROM: reference the preceding user_totals CTE as a table ) -- ③ Main query: JOIN users to user_ranked to add names SELECT r.user_id, u.user_name, -- Retrieve the name from users and add it through the join r.total_amount, r.quartile FROM user_ranked r -- FROM: r is the alias (short name) for user_ranked INNER JOIN users u -- INNER JOIN: combine only rows present in both tables ON r.user_id = u.user_id -- ON: join rows whose user_id values match ORDER BY r.quartile, r.total_amount DESC; -- ORDER BY: quartile ascending, then purchase total descending /* Logical execution order: 1. CTE user_totals: FROM orders → GROUP BY user_id 2. CTE user_ranked: FROM user_totals 3. Main: FROM user_ranked INNER JOIN users ON user_id → Add names 4. Main: ORDER BY quartile, total_amount DESC */
LEGEND
① FROM
FROM ordersRead all 10 rows from orders. This is the input to the first CTE, user_totals.| order_id | user_id | amount |
|---|---|---|
| 1 | U01 | 120,000 |
| 2 | U01 | 50,000 |
| 3 | U02 | 30,000 |
| 4 | U03 | 200,000 |
| 5 | U04 | 75,000 |
| 6 | U05 | 10,000 |
| 7 | U06 | 95,000 |
| 8 | U07 | 40,000 |
| 9 | U08 | 160,000 |
| 10 | U06 | 20,000 |
user_ranked CTE.RANK() assigns positions based on value order, while NTILE(n) divides the row count into n groups and assigns bucket numbers. Unlike RANK, it forces rows into separate buckets even when values tie.step2 cannot use step3, except in the special case of a recursive CTE.CTE + ROW_NUMBER — Extract the Top N Products in Each Category
APIs that retrieve only the top N rows in each category are extremely common. ROW_NUMBER() combined with a CTE is the simplest and most versatile pattern.
WITH ranked AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY category -- Number rows independently within each category ORDER BY sales DESC -- Number from 1 in descending sales order ) AS rn FROM products ) SELECT * FROM ranked WHERE rn <= 2; -- Keep only the top two rows in each category
From the product_sales table below, extract the two products with the highest sales in each category.
Even when values tie, use ROW_NUMBER to force the result to exactly two products.
| product_id | category | product_name | sales |
|---|---|---|---|
| P01 | Food | Apple | 85000 |
| P02 | Food | Banana | 62000 |
| P03 | Food | Orange | 62000 |
| P04 | Food | Grapes | 41000 |
| P05 | Drink | Green Tea | 95000 |
| P06 | Drink | Coffee | 78000 |
| P07 | Drink | Juice | 78000 |
| P08 | Drink | Water | 55000 |
| category | product_name | sales | rn |
|---|---|---|---|
| Drink | Green Tea | 95000 | 1 |
| Drink | Coffee | 78000 | 2 |
| Food | Apple | 85000 | 1 |
| Food | Banana | 62000 | 2 |
WITH ranked_products AS ( -- ① Assign every product a sales position within its category SELECT -- SELECT: specify the columns to retrieve category, product_name, sales, ROW_NUMBER() OVER ( -- ROW_NUMBER(): window function that assigns sequential numbers starting at 1 PARTITION BY category -- PARTITION BY: split by category and reset numbering for each one ORDER BY sales DESC, product_id -- Break equal sales by product_id ascending ) AS rn -- AS rn: define a short alias for row_number FROM product_sales -- FROM: target table ) -- rn cannot be used in WHERE here; a window result is unavailable within the same SELECT -- ② Keep only CTE rows where rn is 2 or less (the top two) SELECT category, product_name, sales, rn FROM ranked_products -- FROM: reference the CTE defined above WHERE rn <= 2 -- WHERE: keep positions 1 and 2 in each category ORDER BY category, rn; -- ORDER BY: sort by category ascending, then position ascending /* Logical execution order: 1. CTE FROM product_sales → Read every product row 2. CTE ROW_NUMBER() OVER(...) → Number each category in descending sales order 3. Outer FROM ranked_products → Reference the CTE result 4. Outer WHERE rn <= 2 → Keep the top two rows in each category 5. Outer ORDER BY category, rn → Sort and return the output */
LEGEND
① FROM
FROM product_salesRead all 8 rows from product_sales. This is the input to the ranked_products CTE.| product_id | category | product_name | sales |
|---|---|---|---|
| P01 | Food | Apple | 85,000 |
| P02 | Food | Banana | 62,000 |
| P03 | Food | Orange | 62,000 |
| P04 | Food | Grapes | 41,000 |
| P05 | Drink | Green Tea | 95,000 |
| P06 | Drink | Coffee | 78,000 |
| P07 | Drink | Juice | 78,000 |
| P08 | Drink | Water | 55,000 |
SELECT *, ROW_NUMBER() OVER(...) AS rn FROM t WHERE rn <= 2 is an error. Always use a CTE or subquery.CTE + LAG — Calculate Month-over-Month Growth
LAG(col, n) is a window function that returns the value n rows before the current row. It is essential for time-series comparisons such as month-over-month, year-over-year, and day-over-day changes.
LAG(amount, 1) OVER (ORDER BY order_month) -- Return amount from the preceding row when ordered by order_month -- The first row has no preceding row, so it returns NULL
Month-over-month formula: (current month − previous month) ÷ previous month × 100. Use NULLIF to prevent division by zero when the previous month is NULL or 0.
LAG(col, 1, 0) returns 0 instead of NULL. Use it when an API response should avoid NULL.The corresponding
LEAD function references a following row.Using the monthly_sales table below, create an API query that calculates each month’s sales, previous-month sales, and month-over-month growth rate (%).
For the first month, which has no preceding month, output NULL for both prev_sales and mom_rate.
| order_month | sales |
|---|---|
| 2024-01 | 100000 |
| 2024-02 | 120000 |
| 2024-03 | 108000 |
| 2024-04 | 135000 |
| 2024-05 | 135000 |
| 2024-06 | 150000 |
| order_month | sales | prev_sales | mom_rate |
|---|---|---|---|
| 2024-01 | 100000 | NULL | NULL |
| 2024-02 | 120000 | 100000 | +20.0 |
| 2024-03 | 108000 | 120000 | -10.0 |
| 2024-04 | 135000 | 108000 | +25.0 |
| 2024-05 | 135000 | 135000 | 0.0 |
| 2024-06 | 150000 | 135000 | +11.1 |
WITH sales_with_lag AS ( -- ① Use LAG to add the preceding row’s sales to each row SELECT order_month, sales, LAG(sales, 1) OVER ( -- LAG(column, n): get the value n rows earlier (NULL if absent) ORDER BY order_month -- ORDER BY: sort months ascending to define what “previous” means ) AS prev_sales -- AS: name the column FROM monthly_sales -- FROM: source table ) -- ② Use the CTE to calculate mom_rate (month-over-month growth) SELECT order_month, sales, prev_sales, ROUND( -- ROUND(value, digits): round to the specified number of digits (sales - prev_sales) -- Difference between current and previous month (the increase) * 100.0 -- Multiply by 100.0 for a percentage and floating-point division / NULLIF(prev_sales, 0) -- NULLIF(a,b): return NULL when a equals b, preventing division by zero , 1) -- Second argument: round to one decimal place AS mom_rate -- Name the calculated result mom_rate FROM sales_with_lag -- FROM: reference the CTE defined above ORDER BY order_month; -- ORDER BY: sort months ascending /* Logical execution order: 1. CTE FROM monthly_sales → Read all rows 2. CTE LAG(sales,1) OVER(...) → Add previous-month sales 3. Outer FROM sales_with_lag → Reference the CTE result 4. Outer ROUND(...) → Calculate month-over-month growth 5. Outer ORDER BY order_month → Sort by month */
LEGEND
① FROM
FROM monthly_salesRead all 6 rows from monthly_sales. This is the input to the sales_with_lag CTE.| order_month | sales |
|---|---|
| 2024-01 | 100,000 |
| 2024-02 | 120,000 |
| 2024-03 | 108,000 |
| 2024-04 | 135,000 |
| 2024-05 | 135,000 |
| 2024-06 | 150,000 |
mom_rate from the prev_sales produced by LAG, first finalize prev_sales in a CTE and use it in the next SELECT, because a LAG result alias cannot be referenced in the same SELECT.(sales - prev_sales) / prev_sales performs integer division, truncates the fraction, and returns zero.* 100.0 must be used first to convert the calculation to floating point.Recursive CTE — Expand Every Level of a Manager-to-Report Hierarchy
A recursive CTE uses WITH RECURSIVE to process a hierarchy iteratively by referencing itself. Common uses include organization charts, category hierarchies, and bills of materials (BOMs).
WITH RECURSIVE org_tree AS ( -- ① Anchor member: recursion starting point (select the top row) SELECT id, name, manager_id, 0 AS depth FROM employees WHERE manager_id IS NULL -- No manager means the top level UNION ALL -- Vertically combine anchor and recursive rows, including duplicates -- ② Recursive member: keep adding direct reports to the preceding result SELECT e.id, e.name, e.manager_id, t.depth + 1 FROM employees e JOIN org_tree t ON e.manager_id = t.id ) SELECT * FROM org_tree;
WHERE depth < 10 to prevent infinite loops.From the self-referencing employees table below, expand the hierarchy of every employee starting from the CEO, whose manager_id is NULL.
For each employee, also output depth (hierarchy depth, with CEO = 0) and path (for example, "CEO→Director→Manager").
| emp_id | name | manager_id |
|---|---|---|
| 1 | Alex (CEO) | NULL |
| 2 | Blake (Sales Director) | 1 |
| 3 | Casey (Engineering Director) | 1 |
| 4 | Drew (Sales Manager) | 2 |
| 5 | Emery (Sales Rep) | 4 |
| 6 | Finley (Tech Lead) | 3 |
| emp_id | name | manager_id | depth | path |
|---|---|---|---|---|
| 1 | Alex (CEO) | NULL | 0 | Alex (CEO) |
| 2 | Blake (Sales Director) | 1 | 1 | Alex (CEO)→Blake (Sales Director) |
| 3 | Casey (Engineering Director) | 1 | 1 | Alex (CEO)→Casey (Engineering Director) |
| 4 | Drew (Sales Manager) | 2 | 2 | Alex (CEO)→Blake (Sales Director)→Drew (Sales Manager) |
| 6 | Finley (Tech Lead) | 3 | 2 | Alex (CEO)→Casey (Engineering Director)→Finley (Tech Lead) |
| 5 | Emery (Sales Rep) | 4 | 3 | Alex (CEO)→Blake (Sales Director)→Drew (Sales Manager)→Emery (Sales Rep) |
-- Recursive CTEs use WITH RECURSIVE (PostgreSQL and BigQuery) WITH RECURSIVE org_tree AS ( -- ① Anchor member: select the CEO row that starts recursion SELECT -- SELECT: columns to retrieve emp_id, name, manager_id, 0 AS depth, -- Set the starting depth to 0 and name the column with AS name AS path -- Initialize path with the employee’s own name FROM employees -- FROM: data source WHERE manager_id IS NULL -- WHERE ... IS NULL: keep the row with no manager, the top level UNION ALL -- UNION ALL: vertically combine SELECT results, including duplicates -- ② Recursive member: keep adding direct reports to org_tree SELECT e.emp_id, e.name, e.manager_id, t.depth + 1, -- Add 1 to the parent depth to descend one level t.path || '→' || e.name -- ||: string concatenation operator in PostgreSQL and similar databases FROM employees e -- FROM: e is the report side, the next level INNER JOIN org_tree t -- INNER JOIN: t is the parent row established in the preceding step ON e.manager_id = t.emp_id -- ON: join where the report’s manager ID equals the parent emp_id WHERE t.depth < 10 -- WHERE: limit recursion to 10 levels to prevent infinite loops ) SELECT emp_id, name, manager_id, depth, path FROM org_tree -- FROM: reference the completed recursive CTE ORDER BY depth, emp_id; -- ORDER BY: sort by depth ascending, then emp_id ascending /* Logical execution order (recursive iterations): 1. Anchor → Generate the CEO starting row 2. Iteration 1 → Expand children 3. Iteration 2 → Expand children 4. Iteration 3 → Expand children 5. Iteration 4 → Stop because no rows are added 6. UNION ALL → ORDER BY → Stack rows and sort */
LEGEND
⓪ FROM
FROM employeesRead all 6 rows from employees. This self-referencing data is the source used to build the organization tree.| emp_id | name | manager_id |
|---|---|---|
| 1 | Alex (CEO) | NULL |
| 2 | Blake (Sales Director) | 1 |
| 3 | Casey (Engineering Director) | 1 |
| 4 | Drew (Sales Manager) | 2 |
| 5 | Emery (Sales Rep) | 4 |
| 6 | Finley (Tech Lead) | 3 |
WHERE depth < N.WHERE depth < 10.