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
| transaction_id | user_id | product_id | quantity | total_amount | transaction_date |
|---|---|---|---|---|---|
| 1067 | 197 | 1001 | 2 | 23.46 | 2026-02-02 |
| 1134 | 294 | 1050 | 3 | 36.93 | 2026-03-03 |
| 1201 | 391 | 1099 | 4 | 50.4 | 2026-04-04 |
| 1268 | 488 | 1148 | 5 | 63.87 | 2026-05-05 |
| 1335 | 585 | 1197 | 0 | 0 | 2026-06-06 |
| 1402 | 682 | 1246 | 2 | 90.81 | 2026-07-07 |
| 1469 | 779 | 1295 | 3 | 104.28 | 2026-08-08 |
| 1536 | 876 | 1344 | 4 | 117.75 | 2026-09-09 |
| 1603 | 973 | 1393 | 5 | -45 | 2026-01-10 |
| 1670 | 1070 | 1442 | 1 | 144.69 | 2026-02-11 |
Expected output
| month | revenue | pct_change |
|---|---|---|
| 2022-01 | 855.12 | NULL |
| 2022-02 | 1528.62 | 78.76 |
| 2022-03 | 696.96 | -54.41 |
| 2022-04 | 1259.22 | 80.67 |
| 2022-05 | 562.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: 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 two months where a percentage simply is not defined: get either 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, not 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 SUM(total_amount) per month in the monthly CTE. This yields one revenue total per month that actually has transactions, which is exactly 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). Because 'YYYY-MM' strings sort lexicographically in true chronological order, 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 the ratio is not truncated to an integer, and the multiplication happens before the rounding.
Step 4: Guard the two undefined cases
Emit null for both cases where a percentage is not well defined: (a) the FIRST month, where LAG returns null because there is no prior row; and (b) any month whose prior present month summed to 0 revenue, where dividing by prev_revenue is undefined. total_amount is REAL and refunds or credits can net a month to zero in this data, so a naive LAG-and-divide would either divide by zero or fabricate a misleading value.
The solution
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, with no join predicate to get wrong, and it runs over the tiny grouped result rather than the raw table.
Common follow-up questions
- If a month has zero transactions and never appears in the grouped result, what does your percentage change compare against, and how would you force a dense calendar spine? (Tests whether the candidate understands that the grouped result omits empty months, so LAG compares against the previous PRESENT month, not the previous calendar month.)
- How would you extend this to show each month against its trailing three-month average instead of just the prior month? (Tests generalizing the window from a single prior row to a moving frame.)
- At 250M rows partitioned by `transaction_date`, what keeps this query cheap, and where in the plan does the window function actually execute? (Tests understanding of where the window runs and how the aggregation controls cost at 250M rows.)