Phase 8 · SQL interview problems, from easy to hard · Topic 8.9

Month-over-month revenue growth

Must knowInterview problemMedium

For each month, show the revenue and the percentage change from the previous month, rounded to one decimal place.

Not started

Step 1 Solve it yourself first

Tables you will use

orders

What it really tests

Aggregating to the right first, then , then safe percentage maths — and division by zero included.

The pattern: Period-over-period change

"Growth", "change from the previous", "month over month", "compared with last week".

Picture it

flowchart LR
  O["orders"] --> M["monthly revenue<br/>Jan 445 · Feb 210 · Mar 138"]
  M --> L["LAG brings the month before<br/>Feb sees 445 · Mar sees 210"]
  L --> G["growth %<br/>Feb −52.8 · Mar −34.3"]
First shrink orders to one per month, then let each month look back at the one before it with LAG, then do the percentage maths.

The approach, step by step

  1. orders to one row per month first (a ).
  2. Use LAG(revenue) OVER (ORDER BY month) to bring last month's revenue onto each row.
  3. Growth % = 100.0 × (this − last) ÷ last. Use 100.0 to avoid integer division and NULLIF(last, 0) to avoid dividing by zero.
  4. The first month has no previous month, so its growth is .

Your turn: write it before you look

Ctrl/⌘ + Enter runs it. Select part of the query to run only that part.
Show the solution

Stuck for more than ten minutes? Open it, read it, close it, and write it again from memory.

Solution: monthly CTE, then LAG

sql
WITH monthly AS (
  SELECT strftime('%Y-%m', order_date) AS month,
         SUM(amount) AS revenue
  FROM orders
  GROUP BY strftime('%Y-%m', order_date)
)
SELECT month, revenue,
       ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
             / LAG(revenue) OVER (ORDER BY month), 1) AS growth_pct
FROM monthly
ORDER BY month;

3 rows

monthrevenuegrowth_pct
2024-01445NULL
2024-02210-52.8
2024-03138-34.3

Another way: A named , and a guard against dividing by zero

sql
WITH monthly AS (
  SELECT strftime('%Y-%m', order_date) AS month,
         SUM(amount) AS revenue
  FROM orders
  GROUP BY strftime('%Y-%m', order_date)
)
SELECT month, revenue,
       ROUND(100.0 * (revenue - LAG(revenue) OVER w)
             / NULLIF(LAG(revenue) OVER w, 0), 1) AS growth_pct
FROM monthly
WINDOW w AS (ORDER BY month)
ORDER BY month;

3 rows

monthrevenuegrowth_pct
2024-01445NULL
2024-02210-52.8
2024-03138-34.3

WINDOW w (…) names the window once. turns a previous revenue of 0 into NULL, so the growth is NULL instead of an error.

Edge cases to say out loud

  • The first month: no previous month, so NULL.
  • A previous month with 0 revenue: divide by NULLIF(…, 0).
  • A month with no orders at all has no row, so LAG would compare with two months ago — build a month list and if that matters.

Common mistakes

  • Using LAG on the order rows instead of the monthly totals.
  • (revenue - prev) / prev * 100 with whole numbers: integer division gives 0 or -1.
  • Grouping by order_date instead of the month.

In other databases

  • : DATE_TRUNC('month', order_date) for the month. : DATE_FORMAT(order_date, '%Y-%m'). : FORMAT(order_date, 'yyyy-MM') or DATEFROMPARTS(YEAR(d), MONTH(d), 1).

Step 2 Go further (free, optional)

Optional. The notes above are written to be enough on their own. These free resources give a second explanation and more practice.

Step 3 Practise

Try each one yourself first: out loud, or written and run in the practice box below, which saves every query you run. Then open the answer and compare.

  1. ExplainSay what it does and when to use it, in plain words, with no notes.

    Explain why you aggregate to months before using LAG.

    Show answer

    LAG returns the previous row, so the rows must be the things you want to compare: months. On the raw orders , the previous row is the previous order, so "growth" would compare one order with another order.

    So first collapse to one row per month ( month, (amount)) in a CTE, and only then use LAG over those monthly rows. The previous row is now the previous month.

    (The order of the steps matters for a second reason too: window functions run after GROUP BY, so even in one , LAG over a SUM is computed on the grouped rows.)

  2. Writefrom a blank page, then run it

    Compute month-over-month growth in the number of orders, not revenue.

    Show answer

    Swap SUM(amount) for (*). January has 4 orders, February 3 (−25%) and March 3 (0%).

    One question to ask: do cancelled orders count? Order 106 is in February's 3. Excluding it would make February 2 and March +50%.

    sql
    WITH monthly AS (
      SELECT strftime('%Y-%m', order_date) AS month,
             COUNT(*) AS orders
      FROM orders
      GROUP BY strftime('%Y-%m', order_date)
    )
    SELECT month, orders,
           ROUND(100.0 * (orders - LAG(orders) OVER (ORDER BY month))
                 / LAG(orders) OVER (ORDER BY month), 1) AS growth_pct
    FROM monthly
    ORDER BY month;

    3 rows

    monthordersgrowth_pct
    2024-014NULL
    2024-023-25
    2024-0330
  3. Predictsay it before you run it

    What would growth_pct be for March if March revenue were 315?

    Show answer

    +50.0. February's revenue is 210, so (315 − 210) ÷ 210 = 105 ÷ 210 = 0.5, which is 50%.

    The query below makes March 315 by adding 177 to order 110 (138 + 177 = 315).

    sql
    UPDATE orders SET amount = amount + 177 WHERE order_id = 110;
    WITH monthly AS (
      SELECT strftime('%Y-%m', order_date) AS month,
             SUM(amount) AS revenue
      FROM orders
      GROUP BY strftime('%Y-%m', order_date)
    )
    SELECT month, revenue,
           ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
                 / LAG(revenue) OVER (ORDER BY month), 1) AS growth_pct
    FROM monthly
    ORDER BY month;

    3 rows

    monthrevenuegrowth_pct
    2024-01445NULL
    2024-02210-52.8
    2024-0331550
  4. Edge casesSay what happens with NULLs, ties, duplicates or no rows at all, and fix it.

    Show what goes wrong if you remove the 100.0 and use whole-number maths, then fix it.

    Show answer

    Revenue is a whole number, so with whole-number maths the division throws away the fraction. (210 − 445) ÷ 445 is −0.53, which becomes 0, and 0 × 100 = 0: February shows 0% growth instead of −52.8%. March does the same. Nothing errors; the numbers are just wrong.

    The fix is to make one side a decimal before dividing: multiply by 100.0 first, as the solution does. Multiplying by 100 (whole) first would be closer but still cut the decimals off: −52 instead of −52.8.

    sql
    WITH monthly AS (
      SELECT strftime('%Y-%m', order_date) AS month,
             SUM(amount) AS revenue
      FROM orders
      GROUP BY strftime('%Y-%m', order_date)
    )
    SELECT month, revenue,
           (revenue - LAG(revenue) OVER (ORDER BY month))
             / LAG(revenue) OVER (ORDER BY month) * 100 AS whole_number_maths,
           ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
                 / LAG(revenue) OVER (ORDER BY month), 1) AS growth_pct
    FROM monthly
    ORDER BY month;

    3 rows

    monthrevenuewhole_number_mathsgrowth_pct
    2024-01445NULLNULL
    2024-022100-52.8
    2024-031380-34.3

Practice box: write and run your answers here

Ctrl/⌘ + Enter runs it. Select part of the query to run only that part.

Step 4 Interview-ready, then prove it

Your interview answer

About 20 to 40 seconds, said out loud.

I aggregate to one row per month in a CTE, then use LAG over month order to bring the previous month's revenue alongside, and compute 100.0 times the difference divided by the previous value, rounded. I guard the division with NULLIF for zero months, note that the first month is NULL, and would fill in missing months with a calendar if gaps are possible.

Follow-up questions

How would you do year-over-year growth on monthly data?

LAG(revenue, 12) OVER (ORDER BY month) — twelve rows back — assuming every month is present.

How would you compute it per product category?

Group by category and month, then LAG(revenue) OVER (PARTITION BY category ORDER BY month).

Tick each check when you can do it without notes

A topic is finished when you pass all four checks, not when you have read it. Be strict: if you had to look at the notes, it is not ticked yet.

  • Explain Say what it does and when to use it, in plain words, with no notes.
  • Write Write the query from a blank page, then run it to check.
  • Predict Read a query and say which rows it returns before you run it.
  • Edge cases Say what happens with NULLs, ties, duplicates or no rows at all, and fix it.

0 of 4 checks ticked.

Saved in this browser. Back it up or move it to another device.