KPI Analysis — Learn Moving Averages, Funnels, Cohort Retention, and Pareto Analysis in Practice
AdvancedKPI AnalysisMoving AverageFunnel Drop-offCohort MatrixPareto AnalysisPostgreSQL-ready5 questions
QUESTION 1
DAU Moving Average — Calculate an N-Day Moving Average with a ROWS BETWEEN Window Frame
AVG OVERROWS BETWEENMoving AverageWindow Frame
Background

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 and RANGE are different: 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.
Problem

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.

Tables used
► daily_active (7 rows)
metric_datedau
2024-03-01100
2024-03-02120
2024-03-0390
2024-03-04150
2024-03-05160
2024-03-06130
2024-03-07200

Note: 03-03 is a dip and 03-07 is a sharp rise. The moving average smooths these swings.

Expected Output

Expected output (metric_date ascending):

metric_datedaudau_ma3
2024-03-01100100.00
2024-03-02120110.00
2024-03-0390103.33
2024-03-04150120.00
2024-03-05160133.33
2024-03-06130146.67
2024-03-07200163.33

The first two rows use partial frames with only one or two rows. On 03-03, (100+120+90)/3 = 103.33.

QUESTION 2
Funnel Drop-off — Calculate Step-to-Step Conversion with GROUP BY × LAG
GROUP BYLAG OVERFunnelStep OrderNULLIF
Background

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
Define the step order yourself: Sorting step as a string produces alphabetical order such as activate, purchase, signup, visit, which breaks the funnel. Assign a numeric step_order with CASE before sorting.
Problem

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.

Tables used
► funnel_events (13 rows)
user_idstep
U1visit
U1signup
U1activate
U1purchase
U2visit
U2signup
U2activate
U3visit
U3signup
U4visit
U4signup
U5visit
U6visit

Note: visit = 6 users, signup = 4, activate = 2, and purchase = 1.

Expected Output

Expected output (funnel order):

stepusersprev_usersstep_cvr
visit6NULLNULL
signup4666.67
activate2450.00
purchase1250.00

Signup drop-off = 100 − 66.67 = 33.33%. The largest leak is signup → activate, with a 50% pass rate.

QUESTION 3
Date Spine — Generate Missing Dates with a Recursive CTE and Zero-Fill Activity
WITH RECURSIVEUNION ALLDate SpineZero-FillingCOALESCE
Background

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
)
Three elements of a recursive CTE: an anchor (the initial row), a recursive term (which references the previous result to generate the next row), and a stop condition (WHERE). A wrong stop condition can create an infinite loop, so always use a condition that eventually becomes false.
Problem

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.

Tables used
► active_days (3 rows, with gaps)
activity_dateactive_users
2024-02-0150
2024-02-0265
2024-02-0440

Note: 02-03 and 02-05 have no row because activity was zero.

Expected Output

Expected output (ascending date, 5 rows):

activity_dateactive_users
2024-02-0150
2024-02-0265
2024-02-030
2024-02-0440
2024-02-050

The 3 source rows become 5 rows; 02-03 and 02-05 are filled with 0.

QUESTION 4
Cohort Retention Matrix — Pivot Elapsed-Week Buckets with FILTER
Multi-CTEFILTERCohort MatrixDate-Difference Buckets
Background

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
FILTER is a WHERE clause for an aggregate: 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.
Problem

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.

Tables used
► logins (9 rows)
user_idlogin_date
U12024-01-01
U12024-01-08
U12024-01-15
U22024-01-01
U22024-01-08
U32024-01-01
U42024-01-08
U42024-01-15
U52024-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

Expected output (2 cohorts × 3 weeks):

cohort_datecohort_sizew0_pctw1_pctw2_pct
2024-01-013100.0066.6733.33
2024-01-082100.0050.000.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.

QUESTION 5
Pareto Analysis — Measure Revenue Concentration with a Cumulative SUM OVER and RANK
SUM OVER ()Running FramePareto AnalysisABC AnalysisRANK
Background

“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
A window alias cannot be reused in the same SELECT: To use cum_share for ABC classification, first materialize the column in WITH ranked AS (...), then reference it in the outer query with CASE WHEN cum_share <= 80 ....
Problem

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.

Tables used
► customer_revenue (5 rows)
customer_idrevenue
C15000
C23000
C31200
C4500
C5300

Note: total revenue = 10000. The top two customers (C1 and C2) contribute 8000 = 80%.

Expected Output

Expected output (revenue descending):

customer_idrevenuerev_rankrev_sharecum_shareabc_class
C15000150.0050.00A
C23000230.0080.00A
C31200312.0092.00B
C450045.0097.00C
C530053.00100.00C

The A group (C1 and C2) accounts for 80% of revenue—the Pareto pattern. Prioritize initiatives for the A group.