WITH: a derived table with a name
The last lesson ended with a derived table nested inside FROM. Here is the same query written as a common table expression.
1
WITH category_stats AS (
SELECT cat.name AS category,
COUNT(*) AS products,
ROUND(AVG(p.price), 2) AS average
FROM products p
JOIN categories cat ON cat.id = p.category_id
GROUP BY cat.id
)
SELECT *
FROM category_stats
WHERE products >= 10
ORDER BY average DESC;
Result — first 5 of 26 rows
| category | products | average |
|---|---|---|
| Wearables | 11 | 1672.29 |
| Programming | 10 | 1585.67 |
| Tablets | 17 | 1529.45 |
| Audio | 11 | 1482.89 |
| Kitchen | 12 | 1443.21 |
Opens Planche in a separate browser tab, with the sample database loaded and this query ready. Every example on the page reuses that tab, and nothing runs until you press F9.
The result is the same, but the query reads in order: define category_stats, then use it. The name exists only for this statement; no table is created.
Several steps in a row
CTEs can be chained, and each CTE can use the ones before it. This turns a nested query into a sequence of named steps.
2
WITH paid_orders AS (
SELECT id, customer_id, total
FROM orders
WHERE status IN ('paid', 'shipped', 'delivered')
),
per_customer AS (
SELECT customer_id,
COUNT(*) AS orders,
ROUND(SUM(total), 2) AS revenue
FROM paid_orders
GROUP BY customer_id
)
SELECT c.first_name || ' ' || c.last_name AS customer,
c.country,
pc.orders,
pc.revenue
FROM per_customer pc
JOIN customers c ON c.id = pc.customer_id
ORDER BY pc.revenue DESC
LIMIT 20;
Result — first 5 of 20 rows
| customer | country | orders | revenue |
|---|---|---|---|
| Sofia Patel | BR | 10 | 70324.42 |
| Zara Novak | JP | 5 | 70149.53 |
| Elena Rossi | KR | 10 | 58867.23 |
| Ines Choi | CA | 6 | 58455.76 |
| Noah Costa | ES | 4 | 57721.52 |
This query has three steps: narrow the orders, summarize them by customer, then attach each customer's name. As nested subqueries it would be read inside out; as CTEs, it follows the processing order.
Window functions: aggregate without collapsing
GROUP BY reduces many rows to one row per group. A window function computes across a set of rows while keeping every row.
3
SELECT p.name,
p.price,
RANK() OVER (ORDER BY p.price DESC) AS price_rank,
DENSE_RANK() OVER (ORDER BY p.price DESC) AS dense_price_rank,
ROW_NUMBER() OVER (ORDER BY p.price DESC) AS row_no
FROM products p
ORDER BY p.price DESC
LIMIT 20;
Result — first 5 of 20 rows
| name | price | price_rank | dense_price_rank | row_no |
|---|---|---|---|---|
| Glide GPS Watch Pro | 2484.4 | 1 | 1 | 1 |
| Terra 3D Puzzle | 2481.77 | 2 | 2 | 2 |
| Aria Castle Kit | 2479.41 | 3 | 3 | 3 |
| Aria LED Strip Pro | 2463.41 | 4 | 4 | 4 |
| Nova Brief Answers | 2445.49 | 5 | 5 | 5 |
OVER (ORDER BY p.price DESC) defines the rows and order used for the calculation. The three functions differ when prices tie:
ROW_NUMBER()— 1, 2, 3, 4. Always distinct, ties broken arbitrarily.RANK()— 1, 2, 2, 4. Ties share a rank and the next value skips.DENSE_RANK()— 1, 2, 2, 3. Ties share a rank and nothing is skipped.
Choose the function based on the result you need. For exactly ten rows, use ROW_NUMBER; for ranked positions that include ties, use a rank function.
Top N per group
Without window functions, this would require a correlated subquery that counts more expensive products in each category. PARTITION BY restarts the numbering for every category.
4
WITH ranked AS (
SELECT cat.name AS category,
p.name AS product,
p.price,
ROW_NUMBER() OVER (PARTITION BY cat.id ORDER BY p.price DESC) AS rank_in_category
FROM products p
JOIN categories cat ON cat.id = p.category_id
)
SELECT category, product, price
FROM ranked
WHERE rank_in_category <= 3
ORDER BY category, rank_in_category;
Result — first 5 of 81 rows
| category | product | price |
|---|---|---|
| Audio | Atlas Earbuds | 2334.28 |
| Audio | Kite Speaker | 2331.15 |
| Audio | Quill Speaker | 2177.84 |
| Bags | Pulse Crossbody Mini | 2377 |
| Bags | Zen Crossbody | 2295.35 |
Running totals
With ORDER BY in the window, each row can include all preceding rows and itself in the calculation.
5
WITH monthly AS (
SELECT substr(ordered_at, 1, 7) AS month,
ROUND(SUM(total), 2) AS revenue
FROM orders
WHERE status IN ('paid', 'shipped', 'delivered')
AND ordered_at >= '2025-01-01'
GROUP BY month
)
SELECT month,
revenue,
ROUND(SUM(revenue) OVER (ORDER BY month), 2) AS running_total
FROM monthly
ORDER BY month;
Result — first 5 of 12 rows
| month | revenue | running_total |
|---|---|---|
| 2025-01 | 311707.73 | 311707.73 |
| 2025-02 | 350775.62 | 662483.35 |
| 2025-03 | 351184.96 | 1013668.31 |
| 2025-04 | 374465.94 | 1388134.25 |
| 2025-05 | 381541.09 | 1769675.34 |
The CTE produces one row per month, and SUM(revenue) OVER (ORDER BY month) accumulates values down that list. The same pattern works for running counts, cumulative percentages, and other calculations that depend on preceding rows.
Comparing to the previous row
6
WITH monthly AS (
SELECT substr(ordered_at, 1, 7) AS month,
ROUND(SUM(total), 2) AS revenue
FROM orders
WHERE status IN ('paid', 'shipped', 'delivered')
AND ordered_at >= '2025-01-01'
GROUP BY month
)
SELECT month,
revenue,
LAG(revenue) OVER (ORDER BY month) AS previous_month,
ROUND(revenue - LAG(revenue) OVER (ORDER BY month), 2) AS change
FROM monthly
ORDER BY month;
Result — first 5 of 12 rows
| month | revenue | previous_month | change |
|---|---|---|---|
| 2025-01 | 311707.73 | NULL | NULL |
| 2025-02 | 350775.62 | 311707.73 | 39067.89 |
| 2025-03 | 351184.96 | 350775.62 | 409.34 |
| 2025-04 | 374465.94 | 351184.96 | 23280.98 |
| 2025-05 | 381541.09 | 374465.94 | 7075.15 |
LAG(revenue) returns the previous row in the window; LEAD returns the next. This makes month-over-month change a single expression instead of a self-join. The first row has no previous month, so previous_month and the resulting change are NULL.
Support
Window functions are widely available: PostgreSQL, SQL Server, and Oracle have supported them for years; SQLite since 3.25 (2018), MySQL since 8.0 (2018), and MariaDB since 10.2. MySQL 5.7 is a common version without them.
What you can do now
You can now express many complex read queries. The remaining lessons cover changing data safely and investigating slow queries.