Daily DAU often contains substantial noise from day-of-week effects and one-off campaigns, making trends hard to read from a raw line chart. A moving average smooths that noise. LAG retrieves only “one point,” but a window frame lets an aggregate operate on the set of the “most recent N rows, including the current row.”
AVG(dau) OVER ( ORDER BY metric_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) -- “the current row” plus “up to two rows earlier” = three rows averaged → 3-day moving average -- Near the beginning, average only the rows that actually exist, even when fewer than three are in the frame
ROWS defines the frame by the physical number of rows. RANGE treats rows with the same ORDER BY value (peers) as a group, so duplicate dates can unexpectedly bring more rows into the frame. For a moving average, using the row-count-based ROWS frame is the standard approach.From the daily_active table, calculate DAU for each date and a three-day moving average (dau_ma3). Return metric_date, dau, dau_ma3 in ascending metric_date order. Round the moving average to two decimal places.
| metric_date | dau |
|---|---|
| 2024-03-01 | 100 |
| 2024-03-02 | 120 |
| 2024-03-03 | 90 |
| 2024-03-04 | 150 |
| 2024-03-05 | 160 |
| 2024-03-06 | 130 |
| 2024-03-07 | 200 |
Note: 03-03 is a dip and 03-07 is a sharp rise. The moving average smooths these swings.
Expected output (metric_date ascending):
| metric_date | dau | dau_ma3 |
|---|---|---|
| 2024-03-01 | 100 | 100.00 |
| 2024-03-02 | 120 | 110.00 |
| 2024-03-03 | 90 | 103.33 |
| 2024-03-04 | 150 | 120.00 |
| 2024-03-05 | 160 | 133.33 |
| 2024-03-06 | 130 | 146.67 |
| 2024-03-07 | 200 | 163.33 |
The first two rows use partial frames with only one or two rows. On 03-03, (100+120+90)/3 = 103.33.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
In the fundamentals version, COUNT(DISTINCT CASE WHEN...) placed every step horizontally in one row. Here, aggregate the data in long format (one row per step) and use LAG() to retrieve the users who passed the previous step, then calculate step-to-step conversion. This pattern scales without rewriting the query when the number of steps changes.
CASE step WHEN 'visit' THEN 1 WHEN 'signup' THEN 2 ... END AS step_order -- String step values do not sort into the business order alphabetically → create an explicit order column LAG(users) OVER (ORDER BY step_order) -- retrieve “the number of users who passed the previous step” in step_order → use it as the denominator
activate, purchase, signup, visit, which breaks the funnel. Assign a numeric step_order with CASE before sorting.From the raw events in funnel_events, output the unique users who passed each step and the pass rate from the previous step (step_cvr) in long format. Return step, users, prev_users, step_cvr in funnel order (ascending step_order). Round step_cvr to two decimal places.
| user_id | step |
|---|---|
| U1 | visit |
| U1 | signup |
| U1 | activate |
| U1 | purchase |
| U2 | visit |
| U2 | signup |
| U2 | activate |
| U3 | visit |
| U3 | signup |
| U4 | visit |
| U4 | signup |
| U5 | visit |
| U6 | visit |
Note: visit = 6 users, signup = 4, activate = 2, and purchase = 1.
Expected output (funnel order):
| step | users | prev_users | step_cvr |
|---|---|---|---|
| visit | 6 | NULL | NULL |
| signup | 4 | 6 | 66.67 |
| activate | 2 | 4 | 50.00 |
| purchase | 1 | 2 | 50.00 |
Signup drop-off = 100 − 66.67 = 33.33%. The largest leak is signup → activate, with a 50% pass rate.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
On a day with no activity, no row exists in the aggregate table. A chart built from this “gapped” data makes the dates appear compressed and shifts moving averages. Use a recursive CTE (WITH RECURSIVE) to generate a continuous date skeleton (a date spine), LEFT JOIN the real data to it, and use COALESCE(..., 0) to fill missing activity with zero.
WITH RECURSIVE date_spine AS ( SELECT DATE '2024-02-01' AS d -- ① anchor: the first row UNION ALL SELECT d + 1 FROM date_spine -- ② recursive term: previous row + 1 WHERE d < DATE '2024-02-05' -- ③ stop condition: stop at the end date )
From active_days, a daily active-user table with missing dates, generate every date from 2024-02-01 through 2024-02-05 with a recursive CTE and output 0 for days without activity. Return activity_date, active_users in ascending date order.
| activity_date | active_users |
|---|---|
| 2024-02-01 | 50 |
| 2024-02-02 | 65 |
| 2024-02-04 | 40 |
Note: 02-03 and 02-05 have no row because activity was zero.
Expected output (ascending date, 5 rows):
| activity_date | active_users |
|---|---|
| 2024-02-01 | 50 |
| 2024-02-02 | 65 |
| 2024-02-03 | 0 |
| 2024-02-04 | 40 |
| 2024-02-05 | 0 |
The 3 source rows become 5 rows; 02-03 and 02-05 are filled with 0.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
The fundamentals version calculated D1/D7 for a single cohort. The advanced version builds a retention matrix (a cohort triangle) across multiple cohorts and elapsed periods. The two keys are to calculate “elapsed week” as (login date − cohort date) / 7, then pivot each week into its own column with the aggregate filter FILTER (WHERE ...).
(l.login_date - f.cohort_date) / 7 AS week_no -- PostgreSQL: DATE − DATE = elapsed days (integer); integer division by /7 gives the week number COUNT(DISTINCT user_id) FILTER (WHERE week_no = 1) -- FILTER: a WHERE condition that applies only to this aggregate; place each week's count in a column
COUNT(*) FILTER (WHERE condition) is equivalent to COUNT(CASE WHEN condition THEN 1 END), but makes the intent clearer and is a standard PostgreSQL feature.From logins, separate users by their first login date and output a retention-rate matrix for elapsed weeks 0, 1, and 2. Return cohort_date, cohort_size, w0_pct, w1_pct, w2_pct in ascending cohort_date order. Round the rates to two decimal places.
| user_id | login_date |
|---|---|
| U1 | 2024-01-01 |
| U1 | 2024-01-08 |
| U1 | 2024-01-15 |
| U2 | 2024-01-01 |
| U2 | 2024-01-08 |
| U3 | 2024-01-01 |
| U4 | 2024-01-08 |
| U4 | 2024-01-15 |
| U5 | 2024-01-08 |
Note: the 01-01 cohort is U1, U2, and U3; the 01-08 cohort is U4 and U5. Every date difference is a multiple of seven days.
Expected output (2 cohorts × 3 weeks):
| cohort_date | cohort_size | w0_pct | w1_pct | w2_pct |
|---|---|---|---|---|
| 2024-01-01 | 3 | 100.00 | 66.67 | 33.33 |
| 2024-01-08 | 2 | 100.00 | 50.00 | 0.00 |
w0 is 100% by definition. For 01-01, w1 = 2/3 and w2 = 1/3; for 01-08, w1 = 1/2 and w2 = 0/2.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
“Eighty percent of revenue comes from the top twenty percent of customers.” Quantify the Pareto principle with SQL. Calculate each customer's revenue share, the cumulative revenue share accumulated in descending order, and an ABC rank based on the cumulative share. The key is to use two window aggregates.
SUM(revenue) OVER () -- OVER() with no frame and no ORDER BY = grand total across all rows (the denominator) SUM(revenue) OVER (ORDER BY revenue DESC ROWS UNBOUNDED PRECEDING) -- running total from the first row through the current row; descending order accumulates from the top
WITH ranked AS (...), then reference it in the outer query with CASE WHEN cum_share <= 80 ....From customer_revenue, output rank, revenue share, cumulative revenue share, and an ABC class in descending revenue order. Return customer_id, revenue, rev_rank, rev_share, cum_share, abc_class. Classify cumulative share ≤80% as A, ≤95% as B, and anything above that as C. Round the percentages to two decimal places.
| customer_id | revenue |
|---|---|
| C1 | 5000 |
| C2 | 3000 |
| C3 | 1200 |
| C4 | 500 |
| C5 | 300 |
Note: total revenue = 10000. The top two customers (C1 and C2) contribute 8000 = 80%.
Expected output (revenue descending):
| customer_id | revenue | rev_rank | rev_share | cum_share | abc_class |
|---|---|---|---|---|---|
| C1 | 5000 | 1 | 50.00 | 50.00 | A |
| C2 | 3000 | 2 | 30.00 | 80.00 | A |
| C3 | 1200 | 3 | 12.00 | 92.00 | B |
| C4 | 500 | 4 | 5.00 | 97.00 | C |
| C5 | 300 | 5 | 3.00 | 100.00 | C |
The A group (C1 and C2) accounts for 80% of revenue—the Pareto pattern. Prioritize initiatives for the A group.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free