Period-over-Period: Beginner
The Growth Rate Question in Disguise
Spot when an interviewer is asking for period-over-period comparison hidden behind "trend," "growth," or "change."
Here is the question you will see in your first product analytics interview: 'show me month-over-month revenue growth per region.' Two minutes later the interviewer has a sense of whether you parsed the prompt correctly. The candidate who reads it, names the pattern, writes a clean three-step CTE, and traces through a small example has signaled three things: they read carefully, they know the pattern, and they reach for LAG without thinking. The candidate who tries to self-join the raw transactions table without pre-aggregating produces a query that returns plausible-looking inflated numbers.
- ▸"growth rate," "change," "trend," "lift," "delta"
- ▸"compare this month/quarter/week to last"
- ▸"month-over-month," "week-over-week," "year-over-year"
- ▸"show the trajectory of X over time"
- ▸"what's the percent change from ... to ..."
What 'prior period' means, and how to spot the question
- Tries to self-join the raw transactions table directly
- Returns inflated numbers from a fan-out
- Has to be corrected when the interviewer asks about the row count
- Loses time recovering from a misread of the question
- Says 'this is period-over-period; I will pre-aggregate first, then use LAG'
- Writes the monthly CTE before any comparison
- Names the offset (LAG returns the immediately previous row in order)
- Has the growth-rate expression on the page within ninety seconds
Why companies care
Truncating Dates into Periods First
Build a period-over-period query by pre-aggregating to period grain then self-joining on offset dates.
- ▸Without it, every transaction pairs with every prior transaction (fan-out)
- ▸Without it, the numbers inflate by an unknown factor
- ▸With it, each period is a single row; the comparison is clean
- ▸Always aggregate to the period grain before any LAG or self-join
The query you should be able to write from memory
Why each CTE matters
The pre-aggregation is the move most candidates skip. They write LAG directly against the raw transactions table, get a different row per transaction, and then try to aggregate the growth rates afterwards. That order is wrong; growth rate is not a per-transaction quantity. Always aggregate first, then compare.
LAG and NULLIF, the two operators that carry the query
Comparing This Period to the Last
Replace the self-join with LAG() OVER (ORDER BY period) for sequential periods, and know when each approach wins.
The self-join alternative
When the self-join wins
- The comparison is to the immediately previous period
- The data has no missing periods within the comparison window
- Performance matters and the engine has good window function support
- Readability matters; the query reads top-to-bottom
- The comparison is to a fixed date offset (same month last year, same day last week)
- The data has missing periods that should remain as gaps
- The engine has weak window function support
- You need to expose extra columns from the prior row that LAG cannot return cleanly
The LEFT JOIN choice in the self-join
If the interviewer asks 'what happens to the first month?', the answer is the same for both approaches. LAG returns NULL for the first row of the partition. LEFT JOIN returns NULL on the prev side. Both produce a NULL growth rate. The first month is honestly represented as 'no prior data,' which is the right rendering.
| Situation | Phrasing that flatlines | Phrasing that lands |
|---|---|---|
| You see a growth or change question | "I'll join transactions to themselves." | "This is period-over-period; I'll pre-aggregate to (region, month) inside a CTE, then LAG over that." |
| The interviewer asks 'what about the first month' | "It'll show wrong, but I can filter it out." | "LAG returns NULL for the first row per partition; the growth rate becomes NULL, and the dashboard renders it as 'no comparison available.'" |
| The prompt is 'growth vs same month last year' | "I'll use LAG with a 12-row offset." | "LAG by row position breaks if any month is missing; I'd self-join the monthly CTE on month_start = prev.month_start + INTERVAL '12 months' so it's a calendar offset, not a positional one." |
| You need to divide by the prior period | "I'll add a CASE WHEN prior = 0." | "NULLIF(prior_revenue, 0) on the divisor; that converts zero priors to NULL growth, which is the honest answer." |
| The data has gaps | "The query still runs." | "Gaps silently shift the comparison; March vs January reads as a normal MoM when it isn't. I'd build a date spine and LEFT JOIN actuals onto it." |
Computing Percent Change Safely
Handle gaps where a period has no data: generate a date spine, LEFT JOIN actuals, and COALESCE to zero.
The bug, stated plainly, and the fix
Suppose a region had revenue in January ($100k), no transactions in February, and revenue in March ($300k). The monthly CTE produces two rows: January and March. LAG(revenue) for March returns January's revenue ($100k), not February's (which would have been $0). The growth rate for March is computed as ($300k - $100k) / $100k = +200%, when the honest answer is 'March vs February, where February was zero, so the growth is undefined or infinite.' The dashboard shows +200% growth. It is wrong.
Zero vs NULL: a business question
The date spine is the single move that most distinguishes the candidate who has shipped reporting from the candidate who has not. Most candidates have heard of the technique; few reach for it by default. Reaching for it without being told is what tells the interviewer you have been on the wrong side of a 'why does our growth chart show -100% in February' Slack ping.
Sketching the Two Rows You Subtract
Calculate period-over-period across multiple segments simultaneously and discuss the fan-out problem.
The two-dimension version
Why the cost matters, and the fan-out trap
- Adds dimensions to the PARTITION BY but not to the GROUP BY
- Joins the monthly aggregate to a daily dimension table at the wrong grain
- Returns the cartesian product when the data is sparse
- Skips the date spine because the query already has multiple dimensions
- Add the dimensions to both GROUP BY and PARTITION BY in matching order
- Pre-aggregate every dimension you need before any JOIN or window function
- Apply the date spine to the cartesian product of all dimensions × months
- Filter sparse combinations inside the CTE if the output is too large
The closing summary
> You are in a product analytics phone screen. The interviewer asks: 'Show me month-over-month revenue growth per region.'
LAG against raw transactions pairs every current-period row with every prior-period row and inflates the numbers; the correct shape is one CTE for the per-period totals and one to compare each period to its predecessor.LAG(revenue) OVER (PARTITION BY region ORDER BY month_start) is the standard pairing tool: the partition resets the comparison at each region and the order defines what prior means. The first period per partition returns NULL, which is the honest answer.LAG when the comparison is a calendar offset rather than the immediately previous row, such as year over year on curr.month_start = prev.month_start + INTERVAL '12 months', because a fixed row offset only lands correctly when the data has no gaps.LEFT JOIN rather than INNER JOIN in the self-join form so the earliest period survives with a NULL prior side instead of being dropped, and wrap the divisor in NULLIF(prev_revenue, 0) so a zero prior period yields NULL rather than an error.LEFT JOIN the actuals onto it, and decide with the consumer whether a missing period means zero activity or missing data.Week-over-week growth is a three-line query once you see the join
- Category
- SQL
- Difficulty
- beginner
- Duration
- 25 minutes
- Challenges
- 0 hands-on challenges
Topics covered: The Growth Rate Question in Disguise, Truncating Dates into Periods First, Comparing This Period to the Last, Computing Percent Change Safely, Sketching the Two Rows You Subtract
Lesson Sections
- The Growth Rate Question in Disguise (concepts: sqlLagLead)
What 'prior period' means, and how to spot the question A period-over-period query computes a value (revenue, active users, signups) for each period, and pairs every row with the corresponding row from the prior period. The pairing rule defines what 'prior' means. For month-over-month, prior is the immediately previous calendar month. For year-over-year, prior is the same month one year ago. For week-over-week, prior is the previous seven-day window. The choice of pairing rule is a business deci
- Truncating Dates into Periods First (concepts: sqlSelfJoin)
The pre-aggregation is mandatory. Computing period-over-period over raw transactions without first rolling up to the period grain produces a fan-out: every transaction in the current month pairs with every transaction in the prior month. The numbers inflate, the dashboard shows nonsense, and the candidate spends the rest of the interview explaining what went wrong. The right shape is always two CTEs: one to compute the per-period totals, one to compare each period to its predecessor. The query y
- Comparing This Period to the Last (concepts: sqlLagLead)
Once you have written the LAG-based query, the interviewer will sometimes ask 'now do it without LAG.' The alternative is a self-join on offset dates. Both produce the same answer; both are common in real codebases; knowing both is the floor at this level. Knowing which one to reach for first is the move. The self-join alternative The self-join produces the same output as the LAG-based query. The differences are stylistic and semantic. The self-join references the monthly CTE twice; the reader's
- Computing Percent Change Safely (concepts: sqlCrossJoin)
The biggest bug in a beginner period-over-period query is missing periods. The query looks right, the SQL is clean, the numbers in the dashboard look plausible , but a region that had no transactions in February shows March's growth computed against January, not against zero. The bug is silent and the dashboard's number is wrong by an unknown factor. This section is about recognizing the bug and fixing it the right way. The bug, stated plainly, and the fix At Airbnb in 2021, the weekly active ho
- Sketching the Two Rows You Subtract (concepts: sqlPartitionBy)
Once the single-dimension version works, the interviewer extends. 'Now do it by region and product.' 'Now by region, product, and channel.' Each new dimension multiplies the partition count. The query is one extra column in the GROUP BY and PARTITION BY; the cost grows multiplicatively. Knowing where the cost comes from is what separates a beginner answer that scales from one that times out. The two-dimension version Two columns in the GROUP BY (region, product), two columns in the PARTITION BY