KPI Analysis — Learn DAU/MAU, CVR, Retention, and ARPU from the Basics
BasicKPI analysisDAU / MAUConversion rateRetention rate / ARPUCOUNT DISTINCT / LAGPostgreSQL-ready5 questions
QUESTION 1
DAU — Calculate Daily Active Users with COUNT(DISTINCT) × GROUP BY
COUNT DISTINCTGROUP BYDAUDeduplication
Background

DAU (Daily Active Users) is a fundamental KPI for measuring a service's “daily engagement.” Because a user is counted as 1 no matter how many times they act on the same day, COUNT(DISTINCT user_id) is required.

COUNT(DISTINCT user_id)  -- unique users (duplicates removed) ← use for DAU
COUNT(user_id)           -- total events (duplicates included) ← not suitable for DAU
COUNT(*)                -- row count (duplicates included, including NULL) ← not suitable for DAU
The definition of “active” differs by product: Whether merely logging in counts as active or using a specific feature is required depends on the requirements. In practice, filter first with a condition such as WHERE event_type IN ('purchase','search'), then use COUNT(DISTINCT).
Problem

From the user_events table, calculate DAU (daily active users) for each date. Return the columns event_date, dau, ordered by event_date ascending.

Tables used
► user_events (9 rows)
event_dateuser_idevent_type
2024-01-01U1view
2024-01-01U2click
2024-01-01U1purchase
2024-01-02U2view
2024-01-02U3view
2024-01-02U4click
2024-01-03U1view
2024-01-03U3click
2024-01-03U3view

※ U1 appears twice on 01-01, and U3 appears twice on 01-03.

Expected Output

Expected output (event_date ascending):

event_datedau
2024-01-012
2024-01-023
2024-01-032

01-01: 2 users, U1 and U2 (U1 duplicate removed); 01-02: 3 users, U2, U3, and U4; 01-03: 2 users, U1 and U3 (U3 duplicate removed).

Model Answer
SELECT
  event_date,
  COUNT(DISTINCT user_id) AS dau  -- count users after removing same-day duplicates
FROM  user_events
GROUP BY event_date
ORDER BY event_date;

/*
  Execution order (SQL's logical evaluation order):
  1. FROM user_events                 → read the rows
  2. GROUP BY event_date              → group by date
  3. COUNT(DISTINCT user_id)          → count after excluding duplicate users
  4. SELECT 2 columns / ORDER BY event_date  → output in ascending date order
  */
Explanation (table transitions & key points)
SELECT event_date, COUNT(DISTINCT user_id) AS dau FROM user_events GROUP BY event_date ORDER BY event_date;
LEGEND
Rows read / loaded
① FROM user_events (9 rows)
FROM user_eventsRead every row from user_events. U1 appears twice on 01-01 and U3 appears twice on 01-03, so COUNT(*) would overstate DAU.
1 / 4
event_dateuser_idevent_type
01-01U1view
01-01U2click
01-01U1purchase
01-02U2view
01-02U3view
01-02U4click
01-03U1view
01-03U3click
01-03U3view
9 rows read
LEARNING POINTS
Understand the difference between COUNT(*), COUNT(col), and COUNT(DISTINCT col): COUNT(*) counts every row, including NULL; COUNT(user_id) counts non-NULL rows, including duplicates; and COUNT(DISTINCT user_id) counts unique non-NULL users. Always use COUNT(DISTINCT user_id) for DAU.
Logical evaluation order for GROUP BY and aggregate functions: SQL's logical order is FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. GROUP BY creates “buckets,” and SELECT calculates an aggregate for each bucket. Because ORDER BY runs after SELECT, the column alias dau can be used.
Extend DAU with a window function and LAG: Put this result in a CTE and use LAG(dau) OVER (ORDER BY event_date) to obtain day-over-day DAU changes and a seven-day moving average. Q2 covers this LAG pattern.
ANTI-PATTERNS
Calculate DAU with COUNT(*) or COUNT(user_id): If the same user accesses once per day, COUNT(*) can be 10 while COUNT(DISTINCT user_id) is 1. Always aggregate event-log tables with COUNT(DISTINCT).
Aggregate without filtering the definition of “active” in WHERE: If all events such as view, click, and purchase are included, a user who merely opened the app is also active. Depending on the requirements, filter with WHERE event_type = 'session_start' or a similar condition before running COUNT(DISTINCT).
Practical column: Choosing between DAU, WAU, and MAU
DAU is the finest-grained metric and shows “today's engagement.” WAU (Weekly Active Users) absorbs day-of-week effects, while MAU (Monthly Active Users) shows monthly scale. A higher DAU / MAU ratio (stickiness) indicates a service used more habitually. A common modern setup refreshes DAU every morning in a dbt daily model and visualizes trends in Looker or Metabase.
QUESTION 2
Month-over-Month MAU Growth — Calculate Monthly KPI Growth Trends with LAG() × CTE
LAG OVERCTEMAU growthMonth-over-monthNULLIF
Background

MAU (Monthly Active Users) is the number of active users who use a service at least once per month, making it a key KPI for measuring product scale and retention. The LAG() window function is useful when analyzing monthly MAU trends. Because it can retrieve the value N rows before the current row, it calculates growth rates such as month-over-month growth without subqueries or self-joins.

LAG(mau) OVER (ORDER BY month)
-- retrieve the mau from the “previous row” in month order
-- return NULL for the first row because there is no previous row

(mau - prev_mau) * 100.0 / NULLIF(prev_mau, 0)
-- NULLIF(prev_mau, 0): return NULL when prev_mau=0 to prevent division by zero
-- * 100.0: convert to floating-point arithmetic and avoid integer division
Reuse the LAG result with a CTE (WITH clause): Repeating LAG more than once is verbose and creates opportunities for mistakes. First create the prev_mau column with WITH lagged AS (...), then reference prev_mau in the outer query for better readability and maintainability.
Problem

From the monthly_kpi table, calculate each month's MAU, previous-month MAU (prev_mau), and month-over-month growth rate (mau_growth_pct). Return month, mau, prev_mau, mau_growth_pct in ascending month order. Round the growth rate to two decimal places.

Tables used
► monthly_kpi (6 rows)
monthmau
2024-011200
2024-021380
2024-031450
2024-041390
2024-051560
2024-061820
Expected Output

Expected output (month ascending):

monthmauprev_maumau_growth_pct
2024-011200NULLNULL
2024-021380120015.00
2024-03145013805.07
2024-0413901450-4.14
2024-051560139012.23
2024-061820156016.67
Model Answer
WITH lagged AS (
  SELECT
    month, mau,
    LAG(mau) OVER (ORDER BY month) AS prev_mau  -- retrieve the previous month's MAU
  FROM  monthly_kpi
)
SELECT
  month, mau, prev_mau,
  ROUND(
    (mau - prev_mau) * 100.0
    / NULLIF(prev_mau, 0), 2  -- avoid division by 0 (NULL when 0)
  ) AS mau_growth_pct         -- month-over-month growth rate (%)
FROM  lagged
ORDER BY month;

/*
  Execution order:
  1. FROM monthly_kpi                         → read the rows
  2. LAG(mau) OVER (ORDER BY month)           → attach the previous month's mau
  3. WITH lagged                              → complete the CTE
  4. (mau-prev_mau)*100.0/NULLIF(prev_mau,0)  → calculate the growth rate
  5. ROUND(..., 2) / ORDER BY month           → round and output in month order
  */
Explanation (table transitions & key points)
WITH lagged AS ( SELECT month, mau, LAG(mau) OVER (ORDER BY month) AS prev_mau FROM monthly_kpi ) SELECT month, mau, prev_mau, ROUND( (mau - prev_mau) * 100.0 / NULLIF(prev_mau, 0), 2 ) AS mau_growth_pct FROM lagged ORDER BY month;
LEGEND
Rows read / loaded
① FROM monthly_kpi (6 rows)
FROM monthly_kpiRead every row from monthly_kpi. Only 2024-04 has lower MAU than the previous month; the goal is to capture this negative growth correctly with LAG.
1 / 4
monthmau
2024-011200
2024-021380
2024-031450
2024-041390
2024-051560
2024-061820
6 rows read
LEARNING POINTS
The three-argument syntax of LAG(col, N, default): LAG(mau) is shorthand for N=1 and default=NULL. LAG(mau, 12, 0) retrieves the mau from 12 months earlier (YoY), or 0 when it does not exist. Year-over-year comparison only requires changing the LEAD/LAG offset to 12.
NULLIF guards both division by zero and division involving NULL: NULLIF(prev_mau, 0) returns NULL when prev_mau is 0, so the subsequent division does not error. In PostgreSQL, NULL / number = NULL without an error, so this is safe.
Define prev_mau once in a CTE: Without a CTE, LAG(mau) would need to be repeated three times in the growth calculation. Defining prev_mau once with WITH lagged AS and referencing it in the outer query is DRY and maintainable.
ANTI-PATTERNS
Forget ORDER BY in OVER(): LAG(mau) OVER () leaves row order undefined, so the database engine may choose the previous row arbitrarily. Always specify OVER (ORDER BY the time-series column) and ORDER BY for LAG/LEAD.
Integer division produces 0: In PostgreSQL, (1380-1200)/1200 is 0 because both operands are integers. Always convert to floating-point arithmetic with * 100.0 or a ::numeric cast.
Practical column: Choosing between MoM, WoW, and YoY
Choose the granularity of growth analysis to match the business cycle. MoM (Month-over-Month) is standard for monthly reports, WoW (Week-over-Week) measures promotion effects, and YoY (Year-over-Year) absorbs seasonal variation. YoY with LAG only requires changing the offset to 12, as in LAG(mau, 12).
QUESTION 3
Conversion Rate — Implement Funnel Analysis with CASE WHEN × COUNT(DISTINCT)
CASE WHENCOUNT DISTINCTCVRFunnel analysisNULLIF
Background

Conversion rate (CVR) measures the share of users who take a target action, such as purchasing or becoming a paying customer, among users who took an earlier action such as visiting or signing up. Funnel analysis breaks this down by step to quantify where users drop off in the path from “signup → trial → purchase.” CVR = users passing a step / users passing the previous step; calculating it this way identifies the bottleneck that needs improvement.

COUNT(DISTINCT CASE WHEN step = 'signup' THEN user_id END)
-- return user_id only for rows where step='signup'; NULL otherwise
-- COUNT(DISTINCT) ignores NULL, so only signup users are counted

ROUND(trial_users * 100.0 / NULLIF(signup_users, 0), 2)
Understand how CASE WHEN returns NULL: CASE WHEN step='signup' THEN user_id END has no ELSE clause, so it automatically returns NULL when the condition is false.
Problem

From funnel_events, output the unique user count and CVR for each step in one row. The output columns are signup_users, trial_users, purchase_users, trial_cvr, purchase_cvr.

Tables used
► funnel_events (10 rows)
user_idstepevent_date
U01signup2024-01-10
U02signup2024-01-10
U03signup2024-01-10
U04signup2024-01-10
U05signup2024-01-10
U01trial_start2024-01-11
U02trial_start2024-01-11
U03trial_start2024-01-12
U01purchase2024-01-15
U02purchase2024-01-16

U04 and U05 dropped before trial. U03 reached trial but did not purchase.

Expected Output

Expected output (one row):

signup_userstrial_userspurchase_userstrial_cvrpurchase_cvr
53260.0066.67

trial_cvr = 3/5×100 = 60.00%, purchase_cvr = 2/3×100 = 66.67%.

Model Answer
SELECT
  COUNT(DISTINCT CASE WHEN step = 'signup'
    THEN user_id END)      AS signup_users,  -- unique count for the step (NULL excluded)
  COUNT(DISTINCT CASE WHEN step = 'trial_start'
    THEN user_id END)      AS trial_users,
  COUNT(DISTINCT CASE WHEN step = 'purchase'
    THEN user_id END)      AS purchase_users,
  ROUND(
    COUNT(DISTINCT CASE WHEN step = 'trial_start' THEN user_id END)
    * 100.0 / NULLIF(COUNT(DISTINCT CASE WHEN step = 'signup'
    THEN user_id END), 0), 2
  ) AS trial_cvr,     -- trial / signup conversion rate (%)
  ROUND(
    COUNT(DISTINCT CASE WHEN step = 'purchase' THEN user_id END)
    * 100.0 / NULLIF(COUNT(DISTINCT CASE WHEN step = 'trial_start'
    THEN user_id END), 0), 2
  ) AS purchase_cvr  -- purchase / trial conversion rate (%)
FROM  funnel_events;

/*
  Execution order:
  1. FROM funnel_events                   → read rows
  2. CASE WHEN step='x' THEN user_id END  → return user_id only for matching steps
  3. COUNT(DISTINCT ...)                  → skip NULL and count unique users
  4. Calculate CVR                        → compute conversion rate from the preceding step
  */
Explanation (table transitions & key points)
SELECT COUNT(DISTINCT CASE WHEN step='signup' THEN user_id END) AS signup_users, COUNT(DISTINCT CASE WHEN step='trial_start' THEN user_id END) AS trial_users, COUNT(DISTINCT CASE WHEN step='purchase' THEN user_id END) AS purchase_users, ROUND( COUNT(DISTINCT CASE WHEN step='trial_start' THEN user_id END) * 100.0 / NULLIF(COUNT(DISTINCT CASE WHEN step='signup' THEN user_id END), 0), 2 ) AS trial_cvr, ROUND( COUNT(DISTINCT CASE WHEN step='purchase' THEN user_id END) * 100.0 / NULLIF(COUNT(DISTINCT CASE WHEN step='trial_start' THEN user_id END), 0), 2 ) AS purchase_cvr FROM funnel_events;
LEGEND
Rows read / loaded
① FROM funnel_events (10 rows)
FROM funnel_eventsRead all rows from funnel_events: 5 signup rows, 3 trial_start rows, and 2 purchase rows. U04 and U05 did not reach trial, while U03 stopped at trial.
1 / 4
user_idstepevent_date
U01signup01-10
U02signup01-10
U03signup01-10
U04signup01-10
U05signup01-10
U01trial_start01-11
U02trial_start01-11
U03trial_start01-12
U01purchase01-15
U02purchase01-16
10 rows read
LEARNING POINTS
COUNT(DISTINCT CASE WHEN...) completes funnel aggregation in one table: Splitting a multi-step table with GROUP BY step produces multiple rows and requires another JOIN to calculate CVR. This pattern pivots unique counts for every step in one query and is highly efficient.
CASE WHEN without ELSE returns NULL: CASE WHEN step='signup' THEN user_id END has no ELSE clause, so it automatically returns NULL when the condition is false. Because COUNT(DISTINCT) ignores NULL, the result counts only signup rows.
Use the previous step's users as the CVR denominator: Instead of overall CVR (purchase/signup = 2/5 = 40%), calculating step-to-step CVR (trial→purchase = 2/3 = 66.67%) shows where the greatest improvement opportunity lies.
ANTI-PATTERNS
Calculate the funnel with COUNT(*) or COUNT(step): If one user passes the same step multiple times, COUNT(*) overcounts. Funnel analysis must use COUNT(DISTINCT user_id).
Treat step passage as a strict sequence: Real users may go from signup → purchase while skipping trial, or repeat trial. This query measures only whether a user reached each step; it does not enforce order. Use a CTE + self-join or window functions when time windows or ordering constraints are required.
Practical column: Implementing a funnel-analysis dashboard
A common production pattern is to define this query as a dbt model and connect it to a Funnel chart in Metabase or Looker. Sending a Slack alert when step-to-step CVR falls below its baseline automates measurement of marketing campaign impact.
QUESTION 4
Retention Rate — Calculate Cohort D1/D7 Retention with CTE + LEFT JOIN
CTELEFT JOINRetention rateCohort analysis
Background

Retention rate is the KPI for the share of users who return after their first visit. D1 retention = the share of users who return the day after their first login; D7 is the share who return seven days later.

WITH first_login AS (
  SELECT user_id, MIN(login_date) AS cohort_date
  FROM  user_logins  GROUP BY user_id
)
-- detect D1/D7 returns
CASE WHEN l.login_date = f.cohort_date + 1 THEN l.user_id END
CASE WHEN l.login_date = f.cohort_date + 7 THEN l.user_id END
Preserve users who did not return with LEFT JOIN: INNER JOIN would leave only users who returned on D1/D7, so the denominator (cohort size) could not be calculated correctly.
Problem

From user_logins, calculate D1 and D7 retention for the 2024-01-01 cohort. The output columns are cohort_date, cohort_size, d1_users, d1_retention, d7_users, d7_retention; round retention to two decimal places.

Tables used
► user_logins (10 rows)
user_idlogin_date
U12024-01-01
U22024-01-01
U32024-01-01
U42024-01-01
U52024-01-01
U12024-01-02
U32024-01-02
U22024-01-08
U32024-01-08
U52024-01-08

U1 and U3 returned on D1 (01-02); U2, U3, and U5 returned on D7 (01-08). U4 never returned.

Expected Output

Expected output (one row):

cohort_datecohort_sized1_usersd1_retentiond7_usersd7_retention
2024-01-015240.00360.00

D1: U1 and U3 return on 01-02 → 2/5=40.00%. D7: U2, U3, and U5 return on 01-08 → 3/5=60.00%.

Model Answer
WITH first_login AS (
  SELECT
    user_id,
    MIN(login_date) AS cohort_date  -- first login date = cohort
  FROM  user_logins
  GROUP BY user_id
)
SELECT
  f.cohort_date,
  COUNT(DISTINCT f.user_id)                     AS cohort_size,
  COUNT(DISTINCT CASE WHEN l.login_date = f.cohort_date + 1
    THEN l.user_id END)                          AS d1_users,  -- returned the next day (+1)
  ROUND(
    COUNT(DISTINCT CASE WHEN l.login_date = f.cohort_date + 1
      THEN l.user_id END) * 100.0
    / NULLIF(COUNT(DISTINCT f.user_id), 0), 2
  )                                                 AS d1_retention,  -- D1 retention (%)
  COUNT(DISTINCT CASE WHEN l.login_date = f.cohort_date + 7
    THEN l.user_id END)                          AS d7_users,
  ROUND(
    COUNT(DISTINCT CASE WHEN l.login_date = f.cohort_date + 7
      THEN l.user_id END) * 100.0
    / NULLIF(COUNT(DISTINCT f.user_id), 0), 2
  )                                                 AS d7_retention
FROM      first_login f
LEFT JOIN user_logins l USING (user_id)  -- join all login records to the cohort
GROUP BY  f.cohort_date
ORDER BY  f.cohort_date;

/*
  Execution order:
  1. CTE first_login                         → aggregate each user's first login date
  2. LEFT JOIN user_logins                   → join all login records to the cohort
  3. CASE WHEN login_date = cohort_date + N  → detect D1/D7 returns
  4. Calculate retention                     → returning users / cohort size
  */
Explanation (table transitions & key points)
WITH first_login AS ( SELECT user_id, MIN(login_date) AS cohort_date FROM user_logins GROUP BY user_id ) SELECT f.cohort_date, COUNT(DISTINCT f.user_id) AS cohort_size, COUNT(DISTINCT CASE WHEN l.login_date = f.cohort_date + 1 THEN l.user_id END) AS d1_users, ROUND( COUNT(DISTINCT CASE WHEN l.login_date = f.cohort_date + 1 THEN l.user_id END) * 100.0 / NULLIF(COUNT(DISTINCT f.user_id), 0), 2 ) AS d1_retention, COUNT(DISTINCT CASE WHEN l.login_date = f.cohort_date + 7 THEN l.user_id END) AS d7_users, ROUND( COUNT(DISTINCT CASE WHEN l.login_date = f.cohort_date + 7 THEN l.user_id END) * 100.0 / NULLIF(COUNT(DISTINCT f.user_id), 0), 2 ) AS d7_retention FROM first_login f LEFT JOIN user_logins l USING (user_id) GROUP BY f.cohort_date ORDER BY f.cohort_date;
LEGEND
Rows read / loaded
① FROM user_logins (10 rows)
FROM user_loginsRead all rows from user_logins. Confirm that every user first logged in on 2024-01-01.
1 / 8
user_idlogin_date
U101-01
U201-01
U301-01
U401-01
U501-01
U101-02
U301-02
U201-08
U301-08
U501-08
10 rows read
LEARNING POINTS
Separate cohort definition and retention calculation with a CTE: The first_login CTE defines each user's first login date, and the main query detects login on day N. When the cohort definition changes, such as to first purchase date, only the CTE needs to change.
PostgreSQL date arithmetic with date + integer: PostgreSQL uses DATE + 1 to get the next day. MySQL uses DATE_ADD(login_date, INTERVAL 1 DAY), while SQL Server uses DATEADD(day,1,login_date).
Include users who did not return in the denominator with LEFT JOIN: U4 never logs in after 01-01, but LEFT JOIN retains the first_login row (U4, 2024-01-01). This makes cohort_size = 5 (including U4) and keeps the D1/D7 denominators accurate.
ANTI-PATTERNS
Use INNER JOIN so that only returning users remain: INNER JOIN user_logins l removes users such as U4 who did not return. The cohort_size becomes too small and retention appears higher than it is, creating optimistic bias.
Self-join user_logins directly without a CTE: A self-join without the first_login CTE is harder to read and more error-prone. Clearly separate cohort-date identification with MIN(login_date) × CTE.
Practical column: Industry benchmarks for retention rates
D1 retention benchmarks vary widely by industry. In mobile games, D1>40% is often a passing line and D7>20% is an established level. For B2B SaaS, monthly retention is a key metric, and >90% is considered healthy. Segment cohorts in SQL, track retention, and overlay its correlation with new-feature release dates in Looker or Redash as a standard product-improvement practice.
QUESTION 5
ARPU — Calculate Average Revenue by Plan Segment with LEFT JOIN + GROUP BY + COALESCE
LEFT JOINGROUP BYARPUSegment analysisCOALESCE
Background

ARPU (Average Revenue Per User) is the KPI for average revenue per user. Comparing it across segments such as plan, channel, or region helps prioritize monetization initiatives.

ARPU = SUM(revenue) / COUNT(DISTINCT user_id)

-- use LEFT JOIN so users without purchase history are not excluded
FROM users u LEFT JOIN purchases p USING (user_id)

-- users without purchases have SUM(amount)=NULL → convert to 0 with COALESCE
COALESCE(SUM(p.amount), 0)

-- user_id is duplicated after LEFT JOIN, so DISTINCT is required
COUNT(DISTINCT u.user_id)
Why COUNT(DISTINCT) matters after JOIN: When one user makes multiple purchases, LEFT JOIN expands that user into multiple rows. Always use COUNT(DISTINCT u.user_id) after the JOIN.
Problem

From the users and purchases tables, calculate user count, total revenue, and ARPU by plan. Return plan, user_count, total_revenue, arpu ordered by descending arpu. Treat revenue for users without purchases as 0, and round arpu to two decimal places.

Tables used
► users (8 rows)
user_idplan
U1premium
U2premium
U3standard
U4standard
U5standard
U6free
U7free
U8free
► purchases (6 rows)
user_idamount
U15000
U13000
U24500
U31500
U42000
U51800

U1 made two purchases (row expansion). U6, U7, and U8 (free) made no purchases (amount=NULL).

Expected Output

Expected output (arpu descending):

planuser_counttotal_revenuearpu
premium2125006250.00
standard353001766.67
free300.00

premium: U1(5000+3000)+U2(4500)=12500 ÷ 2 users = 6250.00. standard: U3+U4+U5=5300 ÷ 3 users = 1766.67.

Model Answer
SELECT
  u.plan,
  COUNT(DISTINCT u.user_id)           AS user_count,
  COALESCE(SUM(p.amount), 0)         AS total_revenue,
  ROUND(
    COALESCE(SUM(p.amount), 0) * 1.0
    / COUNT(DISTINCT u.user_id),  -- DISTINCT required
    2
  )                                   AS arpu
FROM       users     u
LEFT JOIN  purchases p  USING (user_id)
GROUP BY   u.plan
ORDER BY   arpu DESC;

/*
  Execution order:
  1. FROM users u               → read rows
  2. LEFT JOIN purchases p      → join purchases (unbought users are NULL)
  3. GROUP BY u.plan            → group by plan
  4. SUM(p.amount)              → aggregate revenue by plan
  5. COALESCE(SUM(...), 0)      → convert NULL to 0
  6. COUNT(DISTINCT u.user_id)  → count users by plan
  7. ARPU = revenue / count     → calculate average revenue per user
  8. ORDER BY arpu DESC         → output in descending order
  */
Explanation (table transitions & key points)
SELECT u.plan, COUNT(DISTINCT u.user_id) AS user_count, COALESCE(SUM(p.amount), 0) AS total_revenue, ROUND( COALESCE(SUM(p.amount), 0) * 1.0 / COUNT(DISTINCT u.user_id), 2 ) AS arpu FROM users u LEFT JOIN purchases p USING (user_id) GROUP BY u.plan ORDER BY arpu DESC;
LEGEND
Rows read / loaded
① FROM users (8 rows)
FROM users uRead all rows from users. There are three plans: premium (2 users), standard (3 users), and free (3 users).
1 / 5
user_idplan
U1premium
U2premium
U3standard
U4standard
U5standard
U6free
U7free
U8free
8 rows read
LEARNING POINTS
Always use COUNT(DISTINCT u.user_id) after LEFT JOIN: When U1 makes two purchases, LEFT JOIN expands it into two rows. Without DISTINCT, COUNT(u.user_id) counts U1 twice, inflating the denominator and understating ARPU. After any JOIN that can expand rows, use COUNT(DISTINCT).
Handle a zero-revenue group with COALESCE(SUM(amount), 0): After LEFT JOIN, the no-purchase free group has SUM(p.amount)=NULL. COALESCE(SUM(amount), 0) converts NULL to 0 and guarantees the correct ARPU = 0/3 = 0.00.
Convert integer division to floating-point with * 1.0: Because COALESCE(SUM(amount), 0) is an integer, failing to use * 1.0 or a ::numeric cast truncates 1766.67 to 1766.
ANTI-PATTERNS
Calculate ARPU with INNER JOIN for “purchasing users only”: INNER JOIN completely excludes free users (U6/U7/U8). The correct fact that free-plan ARPU is 0 disappears, distorting the full picture. Always use LEFT JOIN.
Calculate with SUM(amount) / COUNT(*): COUNT(*) returns all nine rows after the JOIN, so the denominator is purchase rows plus NULL rows (9), not users (8). Preserve the ARPU definition of revenue divided by users with COUNT(DISTINCT u.user_id).
Practical column: From ARPU to LTV
A standard SaaS unit-economics analysis estimates LTV (Lifetime Value) from monthly ARPU: LTV = ARPU × average lifetime in months. LTV / CAC (customer acquisition cost) ≥ 3 is a common healthy-SaaS benchmark. Monitoring ARPU trends by plan with LAG() also quantifies the impact of price changes and upsell initiatives immediately.