Ebb and Flow

A hard SQL interview practice problem on DataDriven. Write the query and run it against a real database, with instant feedback.

Domain
SQL
Difficulty
hard
Seniority
L4
Asked in
Amazon

Problem

A finance team tracks revenue momentum by watching how each month stacks up against the one before it. Walking forward from the earliest month, total each month's transaction revenue and show how it moved, in percent, from the immediately preceding month.

The rise and fall of revenue, one month against the last.

Tables

transactions

Columns: transaction_id INTEGER, user_id INTEGER, product_id INTEGER, quantity INTEGER, total_amount REAL, transaction_date TEXT

transactions: 10 of 200 rows
transaction_iduser_idproduct_idquantitytotal_amounttransaction_date
10671971001223.462026-02-02
11342941050336.932026-03-03
12013911099450.42026-04-04
12684881148563.872026-05-05
13355851197002026-06-06
14026821246290.812026-07-07
146977912953104.282026-08-08
153687613444117.752026-09-09
160397313935-452026-01-10
1670107014421144.692026-02-11

Expected output

First 5 of 57 rows
monthrevenuepct_change
2022-01855.12NULL
2022-021528.6278.76
2022-03696.96-54.41
2022-041259.2280.67
2022-05562.26-55.35

Worked solution and explanation

What this really tests

This is period-over-period comparison wearing a revenue-tracking costume. The real skill is to line each month up against the one immediately before it and express the delta as a percentage, without losing the first month to a null prior or dividing by a prior that netted to zero. Anyone can sum revenue by month. What separates candidates is the months where a percentage is simply not defined: get those wrong and you either crash on a divide-by-zero or print a number that means nothing. Note what the solution does NOT do: it groups by the calendar month, not by any transaction attribute, and the comparison walks the ordered months instead of using a self-join.


Walking through it

Step 1: Roll transactions up to monthly revenue

Bucket every transaction into its calendar month with STRFTIME('%Y-%m', transaction_date) and take SUM(total_amount) per month in the monthly CTE. This yields one revenue total per month that actually has transactions, which is the row set the prose's 'each month' implies. A month with no transactions never appears here, which is a design fork worth naming out loud.

Step 2: Reach back to the prior month with LAG

In the sequenced CTE, attach each month's prior-month revenue with LAG(revenue) OVER (ORDER BY month). 'YYYY-MM' strings sort lexicographically in true chronological order, so ordering the window by that string makes 'the prior month' resolve to the immediately preceding present month, and the result is deterministic.

Step 3: Turn the gap into a percentage

Compute the change as (revenue - prev_revenue) * 100.0 / prev_revenue and round it to two decimals. The 100.0 forces real division so an engine that keeps integer operands integer cannot truncate the ratio, and the multiplication happens before the rounding.

Step 4: Guard the undefined cases

Emit null wherever a percentage is not defined. That covers the first month, where LAG returns null because there is no prior row, and any month whose prior month summed to 0. On this data only 2022-01 comes out null: the voided sale and the -45.00 refund are absorbed by their months' other sales. The zero guard is insurance a ledger with refunds needs, not a branch this data takes.


The solution

LAG for month-over-month percentage change
WITH monthly AS (
    SELECT STRFTIME('%Y-%m', transaction_date) AS month,
           SUM(total_amount) AS revenue
    FROM transactions
    GROUP BY STRFTIME('%Y-%m', transaction_date)
),
sequenced AS (
    SELECT month,
           revenue,
           LAG(revenue) OVER (ORDER BY month) AS prev_revenue
    FROM monthly
)
SELECT month,
       ROUND(revenue, 2) AS revenue,
       CASE
           WHEN prev_revenue IS NULL THEN NULL
           WHEN prev_revenue = 0    THEN NULL
           ELSE ROUND((revenue - prev_revenue) * 100.0 / prev_revenue, 2)
       END AS pct_change
FROM sequenced
ORDER BY month;
Self-join on month minus one
Joining each month to 'the previous month' by arithmetic on the month key is brittle. It silently drops the comparison whenever a calendar month has no transactions, and the join predicate gets ugly across year boundaries.
LAG over ordered months
LAG over the chronologically ordered present months always pairs a month with the one immediately before it in the data. There is no join predicate to get wrong, and it runs over the tiny grouped result instead of the raw table.

Common follow-up questions