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
WHERE event_type IN ('purchase','search'), then use COUNT(DISTINCT).From the user_events table, calculate DAU (daily active users) for each date. Return the columns event_date, dau, ordered by event_date ascending.
| event_date | user_id | event_type |
|---|---|---|
| 2024-01-01 | U1 | view |
| 2024-01-01 | U2 | click |
| 2024-01-01 | U1 | purchase |
| 2024-01-02 | U2 | view |
| 2024-01-02 | U3 | view |
| 2024-01-02 | U4 | click |
| 2024-01-03 | U1 | view |
| 2024-01-03 | U3 | click |
| 2024-01-03 | U3 | view |
※ U1 appears twice on 01-01, and U3 appears twice on 01-03.
Expected output (event_date ascending):
| event_date | dau |
|---|---|
| 2024-01-01 | 2 |
| 2024-01-02 | 3 |
| 2024-01-03 | 2 |
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).
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 */
LEGEND
① 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.| event_date | user_id | event_type |
|---|---|---|
| 01-01 | U1 | view |
| 01-01 | U2 | click |
| 01-01 | U1 | purchase |
| 01-02 | U2 | view |
| 01-02 | U3 | view |
| 01-02 | U4 | click |
| 01-03 | U1 | view |
| 01-03 | U3 | click |
| 01-03 | U3 | view |
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.dau can be used.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.WHERE event_type = 'session_start' or a similar condition before running COUNT(DISTINCT).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
prev_mau column with WITH lagged AS (...), then reference prev_mau in the outer query for better readability and maintainability.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.
| month | mau |
|---|---|
| 2024-01 | 1200 |
| 2024-02 | 1380 |
| 2024-03 | 1450 |
| 2024-04 | 1390 |
| 2024-05 | 1560 |
| 2024-06 | 1820 |
Expected output (month ascending):
| month | mau | prev_mau | mau_growth_pct |
|---|---|---|---|
| 2024-01 | 1200 | NULL | NULL |
| 2024-02 | 1380 | 1200 | 15.00 |
| 2024-03 | 1450 | 1380 | 5.07 |
| 2024-04 | 1390 | 1450 | -4.14 |
| 2024-05 | 1560 | 1390 | 12.23 |
| 2024-06 | 1820 | 1560 | 16.67 |
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 */
LEGEND
① 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.| month | mau |
|---|---|
| 2024-01 | 1200 |
| 2024-02 | 1380 |
| 2024-03 | 1450 |
| 2024-04 | 1390 |
| 2024-05 | 1560 |
| 2024-06 | 1820 |
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(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.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.(1380-1200)/1200 is 0 because both operands are integers. Always convert to floating-point arithmetic with * 100.0 or a ::numeric cast.LAG(mau, 12).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)
CASE WHEN step='signup' THEN user_id END has no ELSE clause, so it automatically returns NULL when the condition is false.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.
| user_id | step | event_date |
|---|---|---|
| U01 | signup | 2024-01-10 |
| U02 | signup | 2024-01-10 |
| U03 | signup | 2024-01-10 |
| U04 | signup | 2024-01-10 |
| U05 | signup | 2024-01-10 |
| U01 | trial_start | 2024-01-11 |
| U02 | trial_start | 2024-01-11 |
| U03 | trial_start | 2024-01-12 |
| U01 | purchase | 2024-01-15 |
| U02 | purchase | 2024-01-16 |
U04 and U05 dropped before trial. U03 reached trial but did not purchase.
Expected output (one row):
| signup_users | trial_users | purchase_users | trial_cvr | purchase_cvr |
|---|---|---|---|---|
| 5 | 3 | 2 | 60.00 | 66.67 |
trial_cvr = 3/5×100 = 60.00%, purchase_cvr = 2/3×100 = 66.67%.
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 */
LEGEND
① 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.| user_id | step | event_date |
|---|---|---|
| U01 | signup | 01-10 |
| U02 | signup | 01-10 |
| U03 | signup | 01-10 |
| U04 | signup | 01-10 |
| U05 | signup | 01-10 |
| U01 | trial_start | 01-11 |
| U02 | trial_start | 01-11 |
| U03 | trial_start | 01-12 |
| U01 | purchase | 01-15 |
| U02 | purchase | 01-16 |
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.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
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.
| user_id | login_date |
|---|---|
| U1 | 2024-01-01 |
| U2 | 2024-01-01 |
| U3 | 2024-01-01 |
| U4 | 2024-01-01 |
| U5 | 2024-01-01 |
| U1 | 2024-01-02 |
| U3 | 2024-01-02 |
| U2 | 2024-01-08 |
| U3 | 2024-01-08 |
| U5 | 2024-01-08 |
U1 and U3 returned on D1 (01-02); U2, U3, and U5 returned on D7 (01-08). U4 never returned.
Expected output (one row):
| cohort_date | cohort_size | d1_users | d1_retention | d7_users | d7_retention |
|---|---|---|---|---|---|
| 2024-01-01 | 5 | 2 | 40.00 | 3 | 60.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%.
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 */
LEGEND
① FROM user_logins (10 rows)
FROM user_loginsRead all rows from user_logins. Confirm that every user first logged in on 2024-01-01.| user_id | login_date |
|---|---|
| U1 | 01-01 |
| U2 | 01-01 |
| U3 | 01-01 |
| U4 | 01-01 |
| U5 | 01-01 |
| U1 | 01-02 |
| U3 | 01-02 |
| U2 | 01-08 |
| U3 | 01-08 |
| U5 | 01-08 |
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).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)
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.
| user_id | plan |
|---|---|
| U1 | premium |
| U2 | premium |
| U3 | standard |
| U4 | standard |
| U5 | standard |
| U6 | free |
| U7 | free |
| U8 | free |
| user_id | amount |
|---|---|
| U1 | 5000 |
| U1 | 3000 |
| U2 | 4500 |
| U3 | 1500 |
| U4 | 2000 |
| U5 | 1800 |
U1 made two purchases (row expansion). U6, U7, and U8 (free) made no purchases (amount=NULL).
Expected output (arpu descending):
| plan | user_count | total_revenue | arpu |
|---|---|---|---|
| premium | 2 | 12500 | 6250.00 |
| standard | 3 | 5300 | 1766.67 |
| free | 3 | 0 | 0.00 |
premium: U1(5000+3000)+U2(4500)=12500 ÷ 2 users = 6250.00. standard: U3+U4+U5=5300 ÷ 3 users = 1766.67.
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 */
LEGEND
① 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).| user_id | plan |
|---|---|
| U1 | premium |
| U2 | premium |
| U3 | standard |
| U4 | standard |
| U5 | standard |
| U6 | free |
| U7 | free |
| U8 | free |
COUNT(u.user_id) counts U1 twice, inflating the denominator and understating ARPU. After any JOIN that can expand rows, use COUNT(DISTINCT).SUM(p.amount)=NULL. COALESCE(SUM(amount), 0) converts NULL to 0 and guarantees the correct ARPU = 0/3 = 0.00.COALESCE(SUM(amount), 0) is an integer, failing to use * 1.0 or a ::numeric cast truncates 1766.67 to 1766.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).