KPI Analysis — Learn MoM Growth, NTILE Segmentation, Funnel CVR, Cohort Retention, and Recursive CTEs in Practice
AdvancedMoM Growth (LAG)NTILE SegmentationFunnel AnalysisCohort AnalysisRecursive CTE (Date Spine)PostgreSQL-ready5 questions
QUESTION 6
MoM Growth — Pull the Previous Month with LAG() and Calculate Month-over-Month Change
LAGWindow FunctionsMoM GrowthNULLIF
Background

The month-over-month growth rate (MoM) of monthly revenue or MAU is an essential KPI for management reporting. It can be calculated only after placing the current month's value and the previous month's value on the same row. LAG() is a window function that pulls the value from N rows earlier into the current row after the rows have been ordered with ORDER BY, making it the standard tool for this job.

LAG(revenue)     OVER (ORDER BY month)   -- revenue from the previous row (default offset = 1)
LAG(revenue, 12) OVER (ORDER BY month)   -- 12 rows earlier = the same month last year (YoY)
LEAD(revenue)    OVER (ORDER BY month)   -- LEAD pulls the next row instead
Protect the growth-rate division with NULLIF: Growth rate = (current month − previous month) / previous month. If the previous month is 0, division by zero raises an error. Using NULLIF(prev, 0) turns a zero denominator into NULL and avoids the error. For the first month, no previous month exists, so LAG returns NULL and the growth rate naturally becomes NULL.
Problem

From monthly_revenue, calculate each month's revenue, previous-month revenue, previous-month difference (mom_diff), and month-over-month growth rate (mom_pct, %). Return the columns month, revenue, prev_revenue, mom_diff, mom_pct in ascending month order, and round the growth rate to two decimal places.

Tables used
► monthly_revenue (6 rows)
monthrevenue
2024-011000
2024-021200
2024-031100
2024-041500
2024-051500
2024-061800
Expected Output

Expected output (month ascending):

monthrevenueprev_revenuemom_diffmom_pct
2024-011000NULLNULLNULL
2024-021200100020020.00
2024-0311001200-100-8.33
2024-041500110040036.36
2024-051500150000.00
2024-061800150030020.00

For the first month (01), LAG returns NULL, so the growth rate is also NULL. March declined from the previous month, at -8.33%.

QUESTION 7
Customer Segmentation — Split Spending into Equal Quartiles with NTILE() and Create Tiers
NTILEPARTITION/ORDERSegmentationQuartilesCASE
Background

“Top 25% customers” and “cutting spending into quartile-based tiers” are examples of segmentation by equal-sized groups, where NTILE(n) is useful. NTILE divides rows ordered by ORDER BY into n buckets that are as even as possible and assigns each row a bucket number (1–n).

NTILE(4) OVER (ORDER BY spend DESC)  -- split spending into four descending buckets: 1 = top 25%, 4 = bottom 25%
-- When the row count is not evenly divisible, the remainder is distributed one row at a time from the first bucket
--   Example: split 9 rows into 4 → 3,2,2,2 (the first bucket gets one extra row)
The decisive difference from RANK: RANK/DENSE_RANK rank by whether the values are equal. NTILE prioritizes making the row counts as even as possible, regardless of the size of the value gaps. As a result, two customers with very different spending can land in the same tier, while two near-ties can be split at a bucket boundary.
Problem

From customer_spend, split customers into four quartiles in descending spending order and assign each tier a VIP / Gold / Silver / Bronze label. Return user_id, spend, quartile, segment in descending spend order.

Tables used
► customer_spend (9 rows)
user_idspend
U1100
U2250
U3400
U4550
U5700
U6900
U71200
U81500
U91800

Note: 9 rows split into 4 → the one-row remainder goes to the first bucket. The tier sizes are 3,2,2,2.

Expected Output

Expected output (spend descending):

user_idspendquartilesegment
U918001VIP
U815001VIP
U712001VIP
U69002Gold
U57002Gold
U45503Silver
U34003Silver
U22504Bronze
U11004Bronze

Only the first quartile (VIP) has three customers; the others have two each. This is the behavior of assigning the remainder to the earlier buckets.

QUESTION 8
Funnel Analysis — Calculate Step-Level CVR with COUNT(DISTINCT) + FIRST_VALUE/LAG
COUNT(DISTINCT)FIRST_VALUEFunnel/CVRLAGCASE Ordering
Background

Funnel analysis is the starting point for improving conversion: where do users drop off as they move from “view → cart → purchase”? Count the users who reach each step and calculate the conversion rate (CVR) between steps. CVR has two definitions; placing both in one query provides a richer view.

-- ① Overall CVR: the pass-through rate using the entry step as the denominator
FIRST_VALUE(users) OVER (ORDER BY step_no)   -- fix the first value = the entry count
-- ② Step CVR: the conversion rate using the immediately previous step as the denominator
LAG(users) OVER (ORDER BY step_no)           -- the count at the previous step
Give the steps an explicit order: Because 'view'/'cart'/'purchase' are strings, ordering them directly produces alphabetical order. Use CASE step WHEN 'view' THEN 1 ... to assign a numeric step_no that represents the funnel order. In a funnel, order is everything.
Problem

From events (behavior logs), calculate the number of unique users reaching each step, along with overall CVR and step CVR. Return step, users, overall_cvr, step_cvr in funnel order (view→cart→purchase), with CVRs as percentages rounded to two decimal places.

Tables used
► events (12 rows)
user_idstep
U1view
U1cart
U1purchase
U2view
U2cart
U3view
U4view
U4cart
U4purchase
U5view
U6view
U6cart

Note: view reached = 6 users (U1–U6), cart = 4 users (U1,U2,U4,U6), purchase = 2 users (U1,U4).

Expected Output

Expected output (funnel order):

stepusersoverall_cvrstep_cvr
view6100.00NULL
cart466.6766.67
purchase233.3350.00

For purchase, overall CVR = 2/6 = 33.33% (versus the entry step), while step CVR = 2/4 = 50.00% (versus cart). The largest drop-off is between cart and purchase.

QUESTION 9
Cohort Retention — Use Conditional Aggregation by Month Offset to Build a Retention Matrix
CohortsFILTER AggregationRetentionPivotNULLIF
Background

Cohort analysis groups users by “when they were acquired (their registration month)” and tracks retention by elapsed month afterward. It enables cross-generation comparisons of acquisition campaigns and product improvements. The two keys are calculating the month offset and expanding it into columns with conditional aggregation (a pivot).

-- month offset = active month − cohort month (difference in whole months)
(EXTRACT(YEAR FROM active_month) - EXTRACT(YEAR FROM cohort_month)) * 12
  + (EXTRACT(MONTH FROM active_month) - EXTRACT(MONTH FROM cohort_month))

-- Pivot: place active-user counts for each elapsed month side by side (deduplicated)
COUNT(DISTINCT user_id) FILTER (WHERE month_offset = 1)   -- active users one month later
Retention rate = active users at month N ÷ cohort size at month 0: The denominator for each cohort is the number of users in the registration month (offset=0). Retention is the share of that cohort still active at each elapsed month. Use NULLIF(m0, 0) to prevent division by zero. Churn rate (Q10 in the basic set) is the inverse relationship (1 − retention).
Problem

From activity (registration-month cohort × active month), calculate each cohort's initial size and one-month and two-month retention rates. Return cohort_month, cohort_size, ret_m1, ret_m2 in ascending cohort_month order, with rates as percentages rounded to one decimal place.

Tables used
► activity (11 rows)
user_idcohort_monthactive_month
U12024-012024-01
U12024-012024-02
U12024-012024-03
U22024-012024-01
U22024-012024-02
U32024-012024-01
U42024-022024-02
U42024-022024-03
U42024-022024-04
U52024-022024-02
U52024-022024-03

Note: the 2024-01 cohort = {U1,U2,U3}, and the 2024-02 cohort = {U4,U5}. Each row records that the user was active in that month.

Expected Output

Expected output (cohort_month ascending):

cohort_monthcohort_sizeret_m1ret_m2
2024-01366.733.3
2024-022100.050.0

January cohort: 2 of 3 users one month later (66.7%) and 1 of 3 two months later (33.3%). The February cohort retains 100% after one month, which may indicate stronger acquisition quality.

QUESTION 10
Recursive CTE — Generate a Date Spine and Build a Continuous Time Series with Missing Days Filled as 0
WITH RECURSIVEUNION ALLDate SpineLEFT JOINCOALESCE
Background

Daily sales and DAU often have no row at all on days without activity. If you feed this data directly into a line chart or moving average (basic Q6), missing days are compressed and both the chart and calculations shift. The solution is to generate a continuous date skeleton (a date spine), left join the actuals onto it, and fill missing days with 0. A recursive CTE is the classic way to generate the skeleton.

WITH RECURSIVE calendar AS (
  SELECT DATE '2024-03-01' AS d        -- ① anchor: one starting row (non-recursive term)
  UNION ALL                              -- ② stack the results vertically
  SELECT d + 1 FROM calendar          -- ③ recursive term: reference itself and add one day
  WHERE d < DATE '2024-03-05'           -- ④ stop condition: stop when the endpoint is reached
)
A recursive CTE repeats “current result → add one row → reference itself again”: The anchor creates one starting row, and the recursive term uses the row generated immediately before to create the next row. Without a WHERE stop condition, or with a loose one, the recursion loops forever and stops with a database recursion-limit error. Follow this build-up one step at a time in the visualization.
Problem

Turn the sparse daily_sales data into a continuous time series where all five days from 2024-03-01 to 2024-03-05 appear and days without activity have sales 0. Return event_date, sales in ascending date order. Generate the date spine with a recursive CTE and fill it with LEFT JOIN + COALESCE.

Tables used
► daily_sales (3 sparse rows)
event_datesales
2024-03-01100
2024-03-03150
2024-03-0480

Note: 03-02 and 03-05 have no rows (they are missing zero-sales days). The goal is to fill them in.

Expected Output

Expected output (date ascending, all 5 days):

event_datesales
2024-03-01100
2024-03-020
2024-03-03150
2024-03-0480
2024-03-050

The missing dates 03-02 and 03-05 are filled with 0, making five consecutive days. Moving averages and day-over-day comparisons can now be calculated correctly.