Multi-step queries and calculations across rows: CTEs and window functions

CTEs split a long query into named steps. Window functions calculate ranks, running totals, and neighbouring values without reducing the result to one row per group.

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;
Run in Planche

Result — first 5 of 26 rows

categoryproductsaverage
Wearables111672.29
Programming101585.67
Tablets171529.45
Audio111482.89
Kitchen121443.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;
Run in Planche

Result — first 5 of 20 rows

customercountryordersrevenue
Sofia PatelBR1070324.42
Zara NovakJP570149.53
Elena RossiKR1058867.23
Ines ChoiCA658455.76
Noah CostaES457721.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;
Run in Planche

Result — first 5 of 20 rows

namepriceprice_rankdense_price_rankrow_no
Glide GPS Watch Pro2484.4111
Terra 3D Puzzle2481.77222
Aria Castle Kit2479.41333
Aria LED Strip Pro2463.41444
Nova Brief Answers2445.49555

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;
Run in Planche

Result — first 5 of 81 rows

categoryproductprice
AudioAtlas Earbuds2334.28
AudioKite Speaker2331.15
AudioQuill Speaker2177.84
BagsPulse Crossbody Mini2377
BagsZen Crossbody2295.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;
Run in Planche

Result — first 5 of 12 rows

monthrevenuerunning_total
2025-01311707.73311707.73
2025-02350775.62662483.35
2025-03351184.961013668.31
2025-04374465.941388134.25
2025-05381541.091769675.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;
Run in Planche

Result — first 5 of 12 rows

monthrevenueprevious_monthchange
2025-01311707.73NULLNULL
2025-02350775.62311707.7339067.89
2025-03351184.96350775.62409.34
2025-04374465.94351184.9623280.98
2025-05381541.09374465.947075.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.

Try it yourself

  1. For each country, who is the single customer with the most loyalty points?

    Show the answer

    Show the answer

    WITH ranked AS (
      SELECT country,
             first_name || ' ' || last_name AS customer,
             loyalty_points,
             ROW_NUMBER() OVER (PARTITION BY country ORDER BY loyalty_points DESC) AS rn
      FROM customers
    )
    SELECT country, customer, loyalty_points
    FROM ranked
    WHERE rn = 1
    ORDER BY loyalty_points DESC;
    Run in Planche

    Result — first 5 of 12 rows

    countrycustomerloyalty_points
    FRMinjun Meyer5000
    CAHugo Sato4994
    JPLina Smith4992
    KRLucas Ahmed4992
    AUSana Ahmed4988

    The same top-N-per-group shape as the lesson, with rn = 1 instead of <= 3. Note that ties are broken arbitrarily by ROW_NUMBER; use RANK instead if a tie should return both people.

  2. Orders per month in 2025, with a running total of orders alongside.

    Show the answer

    Show the answer

    WITH monthly AS (
      SELECT substr(ordered_at, 1, 7) AS month, COUNT(*) AS orders
      FROM orders
      WHERE ordered_at >= '2025-01-01'
      GROUP BY month
    )
    SELECT month,
           orders,
           SUM(orders) OVER (ORDER BY month) AS running_orders
    FROM monthly
    ORDER BY month;
    Run in Planche

    Result — first 5 of 12 rows

    monthordersrunning_orders
    2025-017474
    2025-0272146
    2025-0384230
    2025-0470300
    2025-0581381

    A CTE for the monthly counts, then a window function over that. Any “cumulative” column is this shape: aggregate first, then SUM(...) OVER (ORDER BY ...).