KPI Analysis — Learn Moving Averages, Sales Mix/Pareto, Rankings, HAVING, and Churn Rate from the Basics
BasicMoving AverageSales Mix / ParetoRankingHAVING / FILTERChurn Rate (Anti-Join)PostgreSQL-ready5 questions
QUESTION 6
DAU Moving Average — Smooth Trend Noise with AVG() OVER + ROWS BETWEEN
AVG OVERROWS BETWEENMoving AverageWindow Frame
Background

Daily KPIs such as DAU and sales can fluctuate sharply because of day-of-week effects, campaigns, and accidental spikes. A moving average smooths this noise and highlights the underlying trend. Adding a frame clause (ROWS BETWEEN ...) to a window function lets you specify only the “most recent N rows, including the current row” as the aggregation range.

AVG(dau) OVER (
  ORDER BY event_date
  ROWS BETWEEN 2 PRECEDING AND CURRENT ROW  -- today + previous 2 days = 3-day window
)
-- Without an explicit frame, the default is RANGE UNBOUNDED PRECEDING ... CURRENT ROW
-- = a cumulative average from the first row, not a moving average (important)
ROWS vs. RANGE: ROWS divides the frame by the physical number of rows (the most recent three rows). RANGE treats rows with the same ORDER BY value as a group. Moving averages need a row-count frame, so always use ROWS.
Problem

From the daily_active table, calculate DAU for each date and a three-day moving average including the current date (dau_ma3). Return event_date, dau, dau_ma3 in ascending event_date order, and round the moving average to two decimal places.

Tables used
► daily_active (7 rows)
event_datedau
2024-03-01100
2024-03-02120
2024-03-03110
2024-03-0490
2024-03-05150
2024-03-06140
2024-03-07160

※ In practice, change 2 PRECEDING to 6 PRECEDING for a seven-day moving average (the syntax is the same).

Expected Output

Expected output (event_date ascending):

event_datedaudau_ma3
2024-03-01100100.00
2024-03-02120110.00
2024-03-03110110.00
2024-03-0490106.67
2024-03-05150116.67
2024-03-06140126.67
2024-03-07160150.00

The first two days have only one or two rows available, so their averages use partial frames. 03-04 = (120+110+90)/3 = 106.67.

Model Answer
SELECT
  event_date,
  dau,
  ROUND(
    AVG(dau) OVER (
      ORDER BY event_date
      ROWS BETWEEN 2 PRECEDING AND CURRENT ROW  -- 3-day window including today
    ), 2
  ) AS dau_ma3
FROM  daily_active
ORDER BY event_date;

/*
  Execution order (SQL's logical evaluation order):
  1. FROM daily_active             → read the rows
  2. ORDER BY event_date (inside the window) → fix the frame order
  3. AVG(dau) OVER (... ROWS ...)  → average today + previous 2 days
  4. ROUND(..., 2)                 → round to two decimal places
  5. SELECT / ORDER BY event_date  → output in ascending date order
  */
Explanation (table transitions & key points)
SELECT event_date, dau, ROUND( AVG(dau) OVER ( ORDER BY event_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ), 2 ) AS dau_ma3 FROM daily_active ORDER BY event_date;
LEGEND
Rows read / loaded
① FROM daily_active (7 rows)
FROM daily_activeRead all daily DAU rows. The drop on 03-04 and rise from 03-05 through 03-07 show large day-to-day fluctuations, making the trend difficult to read from raw values.
1 / 4
event_datedau
03-01100
03-02120
03-03110
03-0490
03-05150
03-06140
03-07160
7 rows read
LEARNING POINTS
The frame clause separates a moving average from a cumulative average: If you omit the frame on a window with ORDER BY, the default is RANGE UNBOUNDED PRECEDING, a cumulative average from the first row. To limit it to the most recent N rows, always specify ROWS BETWEEN n PRECEDING AND CURRENT ROW.
The beginning uses a partial frame: 03-01 has no preceding row, so its average uses one row; 03-02 uses two rows. This is the specified behavior, and the first six days of a seven-day moving average also have insufficient rows. If you need only points with a complete seven-day window, filter with COUNT(*) OVER (...) = 7.
Change 2 to 6 for a seven-day moving average: ROWS BETWEEN 6 PRECEDING AND CURRENT ROW produces a seven-day moving average, while 3 PRECEDING AND 3 FOLLOWING produces a centered moving average. Changing only the frame boundaries expresses many kinds of smoothing.
ANTI-PATTERNS
Omitting the frame and accidentally producing a cumulative average: This is a very common bug. AVG(dau) OVER (ORDER BY event_date) produces a cumulative average from the beginning, which becomes increasingly slow to react as days pass. A ROWS frame is required for a moving average.
Hand-aggregating N days with a self-join: A self-join such as JOIN ... ON b.date BETWEEN a.date-2 AND a.date is verbose, and missing dates can shift the window. A window frame is accurate and fast in one line.
Practical column: Day-of-week effects and moving-average window size
B2C services often have a day-of-week effect in which DAU rises on weekends, and a three-day moving average leaves that weekly wave visible. A seven-day moving average averages a full week, offsetting the day-of-week effect and making it ideal for monitoring weekly trends. Shorter windows react faster but contain more noise; longer windows are smoother but detect changes more slowly—understanding this sensitivity trade-off is central to choosing the window size.
QUESTION 7
Sales Mix and Pareto Analysis — Implement ABC Analysis with SUM() OVER () and Running Totals
SUM OVER ()Running TotalSales MixPareto / ABC
Background

“What percentage of sales does each category represent?” and “How far toward the total do we get when accumulating categories from the top?” are the core questions of Pareto and ABC analysis for identifying priority products. Window functions let you place the grand total and a running total next to each detail row without removing the detail rows.

SUM(sales) OVER ()                       -- empty OVER = one window over all rows; attach the grand total to each row
SUM(sales) OVER (ORDER BY sales DESC)   -- running total in the specified order
The decisive difference from GROUP BY: GROUP BY collapses and removes detail rows. A window function keeps the detail rows and adds an aggregate value beside them, so a calculation such as “each row's value ÷ grand total = sales mix” can be completed in one query.
Problem

From the category_sales table, which contains pre-aggregated sales by category, calculate the sales mix (sales_pct) and cumulative sales mix in descending sales order (cum_pct). Return category, sales, sales_pct, cum_pct in descending sales order, and round the ratios to two decimal places.

Tables used
► category_sales (5 rows)
categorysales
Electronics5000
Apparel3000
Home1500
Books800
Toys700

※ Grand total = 5000+3000+1500+800+700 = 11000.

Expected Output

Expected output (sales descending):

categorysalessales_pctcum_pct
Electronics500045.4545.45
Apparel300027.2772.73
Home150013.6486.36
Books8007.2793.64
Toys7006.36100.00

At 72.73% cumulative, the top two categories account for about seven-tenths of the total (the Pareto A group).

Model Answer
SELECT
  category,
  sales,
  ROUND(sales * 100.0 / SUM(sales) OVER (), 2) AS sales_pct,  -- sales mix = each row ÷ grand total
  ROUND(
    SUM(sales) OVER (ORDER BY sales DESC) * 100.0      -- running total
    / SUM(sales) OVER (), 2
  ) AS cum_pct                                              -- cumulative sales mix
FROM category_sales
ORDER BY sales DESC;

/*
  Execution order (SQL's logical evaluation order):
  1. FROM category_sales                    → read the rows
  2. SUM(sales) OVER ()                     → attach the grand total to every row
  3. SUM(sales) OVER (ORDER BY sales DESC)  → calculate the running total in sales-descending order
  4. SELECT calculates the ratios            → calculate the sales mix
  5. ORDER BY sales DESC                    → output in descending sales order
  */
Explanation (table transitions & key points)
SELECT category, sales, ROUND(sales * 100.0 / SUM(sales) OVER () , 2) AS sales_pct, ROUND( SUM(sales) OVER (ORDER BY sales DESC) * 100.0 / SUM(sales) OVER () , 2) AS cum_pct FROM category_sales ORDER BY sales DESC;
LEGEND
Rows read / loaded
① FROM category_sales (5 rows)
FROM category_salesRead the pre-aggregated sales by category. The data is already close to sales-descending order, but the window function's ORDER BY guarantees the order.
1 / 4
categorysales
Electronics5000
Apparel3000
Home1500
Books800
Toys700
5 rows read
LEARNING POINTS
An empty OVER () means “all rows are one window”: Unlike GROUP BY, SUM(sales) OVER () does not collapse rows; it attaches the grand total to every detail row. This lets you calculate detail ÷ grand total on the same row. It is more concise than recalculating the grand total in a subquery.
SUM with ORDER BY becomes a running total: SUM(sales) OVER (ORDER BY sales DESC) uses the default frame from the first row through the current row, so it returns a running total. Running total ÷ grand total is the cumulative sales mix, the y-value of the Pareto curve.
* 100.0 avoids integer division: If sales remains an integer, 3000/11000 is truncated to 0. Use * 100.0 (or ::numeric) to promote the calculation to floating point before ROUND.
ANTI-PATTERNS
Letting ties distort the running total: The default RANGE frame combines rows with the same ORDER BY value, so equal-sales rows can make the running total jump by a group. When you need to accumulate one row at a time, use ROWS as in ORDER BY sales DESC ROWS UNBOUNDED PRECEDING and add a tie-breaker column.
Collapsing rows with GROUP BY to calculate the sales mix: GROUP BY removes detail rows, so you cannot place each row's sales mix beside it. Comparing each detail row with the whole while preserving detail is the role of window functions.
Practical column: The Pareto principle and ABC analysis
The Pareto principle (80/20)—“the top 20% of products generate 80% of sales”—guides inventory, shelf placement, and sales-resource allocation. A standard ABC analysis divides the cumulative sales mix into about 70% for group A, about 90% for group B, and the remainder for group C. The cumulative mix supports decisions such as never allowing group A to go out of stock and aggressively rationalizing group C. In BI tools, a Pareto chart that overlays a cum_pct line on descending sales bars is a standard visualization.
QUESTION 8
Category Ranking — PARTITION BY with RANK / DENSE_RANK / ROW_NUMBER
RANKPARTITION BYRankingTop-NDENSE_RANK
Background

Group-internal rankings such as “the top three products in each category” and “rank within a department” are implemented with PARTITION BY. There are three ranking functions, and the most important point is that they handle ties differently.

RANK()       OVER (PARTITION BY category ORDER BY sales DESC)  -- ties share a rank; skip the next  1,1,3
DENSE_RANK() OVER (PARTITION BY category ORDER BY sales DESC)  -- ties share a rank; do not skip  1,1,2
ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC)  -- force consecutive numbers      1,2,3
PARTITION BY is “GROUP BY for a window”: GROUP BY collapses rows, while PARTITION BY resets the ranking within each group while preserving the rows. Rank 1 is assigned again at the beginning of each partition.
Problem

From the product_sales table, assign the within-category sales rank in three ways: RANK, DENSE_RANK, and ROW_NUMBER. Return category, product, sales, rnk, dense_rnk, row_num ordered by category ascending, sales descending, and product ascending.

Tables used
► product_sales (7 rows)
categoryproductsales
FoodApple500
FoodBread500
FoodCake300
FoodDonut200
BeverageCola400
BeverageTea250
BeverageWater150

※ Food's Apple and Bread both have sales of 500 (a tie). This is where the three functions differ.

Expected Output

Expected output (category ascending, sales descending):

categoryproductsalesrnkdense_rnkrow_num
BeverageCola400111
BeverageTea250222
BeverageWater150333
FoodApple500111
FoodBread500112
FoodCake300323
FoodDonut200434

Apple and Bread tie: RANK gives both 1 and makes the next rank 3 (skipping 2), DENSE_RANK makes the next rank 2, and ROW_NUMBER forces the numbers 1 and 2.

Model Answer
SELECT
  category,
  product,
  sales,
  RANK()       OVER (PARTITION BY category ORDER BY sales DESC)          AS rnk,        -- ties share a rank; skip the next
  DENSE_RANK() OVER (PARTITION BY category ORDER BY sales DESC)          AS dense_rnk,  -- ties share a rank; do not skip
  ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC, product) AS row_num     -- force consecutive numbers (tie-break with a second key)
FROM product_sales
ORDER BY category, sales DESC, product;

/*
  Execution order (SQL's logical evaluation order):
  1. FROM product_sales          → read the rows
  2. PARTITION BY category       → split by category
  3. ORDER BY sales DESC (inside the window) → sales descending within each partition
  4. RANK/DENSE_RANK/ROW_NUMBER  → assign ranks
  5. ORDER BY                    → arrange the final display order
  */
Explanation (table transitions & key points)
SELECT category, product, sales, RANK() OVER (PARTITION BY category ORDER BY sales DESC) AS rnk, DENSE_RANK() OVER (PARTITION BY category ORDER BY sales DESC) AS dense_rnk, ROW_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC, product) AS row_num FROM product_sales ORDER BY category, sales DESC, product;
LEGEND
Rows read / loaded
① FROM product_sales (7 rows)
FROM product_salesRead sales by category and product. Food's Apple and Bread are tied at 500; keep that tie in mind.
1 / 4
categoryproductsales
FoodApple500
FoodBread500
FoodCake300
FoodDonut200
BeverageCola400
BeverageTea250
BeverageWater150
7 rows read
LEARNING POINTS
The three functions differ in how they handle ties: When values tie, RANK assigns the same rank and skips the next number (1,1,3), DENSE_RANK does not skip (1,1,2), and ROW_NUMBER assigns a unique sequence even for a tie (1,2). The function depends on what “within the top three” means for the requirement.
PARTITION BY resets ranks by group: PARTITION BY category gives Beverage and Food independent sequences. Without PARTITION BY, one continuous ranking is assigned across the entire table.
Wrap Top-N filtering in a subquery or CTE: Window functions are evaluated after WHERE, so you cannot write WHERE row_num <= 3 directly. The standard pattern is WITH ranked AS (SELECT ..., ROW_NUMBER() ...) SELECT * FROM ranked WHERE row_num <= 3.
ANTI-PATTERNS
Forgetting a tie-breaker column for ROW_NUMBER: When Apple and Bread have the same sales, omitting a second key makes it non-deterministic which one receives 1. Add a column that makes the order unique, such as ORDER BY sales DESC, product, to stabilize the result.
Using ROW_NUMBER for “Top 3” and losing ties: If two items tie at third place, ROW_NUMBER includes only one of them. Use RANK or DENSE_RANK when tied items should also be included. Select the function based on whether the requirement includes ties.
Practical column: Ranking KPIs in real-world scenarios
“Store-by-store sales ranking,” “Top 10 traffic sources by keyword,” and “extract only the latest order for each user” are common uses of PARTITION BY + ROW_NUMBER in analytical SQL. In particular, the “latest one row” idiom—extract the latest record for each user with ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) = 1—appears frequently in deduplication and current-state retrieval, so it is worth mastering.
QUESTION 9
Heavy-User Extraction — Segment with GROUP BY + HAVING + FILTER
GROUP BYHAVINGSegment ExtractionFILTER
Background

HAVING narrows the result by an aggregate condition, such as “repeat customers with at least two completed orders.” Understanding its division of labor with WHERE, which filters individual rows, is essential. PostgreSQL's FILTER clause also lets you specify the rows counted by each aggregate function in a readable way.

-- WHERE  : filter “rows” before grouping
-- HAVING : filter “groups” after grouping (aggregate values can be used)

COUNT(*) FILTER (WHERE status = 'completed')   -- count only rows matching the condition
-- = equivalent to SUM(CASE WHEN status='completed' THEN 1 ELSE 0 END), but more readable
Recall the logical evaluation order: FROM → WHERE → GROUP BY → aggregation → HAVING → SELECT → ORDER BY. HAVING runs after aggregation, so it can use an aggregate value such as COUNT(*) >= 2. WHERE cannot use an aggregate value because aggregation has not happened yet.
Problem

From the orders table, extract heavy users with at least two completed orders, and return their completed-order count and completed-amount total. Return user_id, completed_orders, completed_amount ordered by completed_amount descending.

Tables used
► orders (8 rows)
user_idorder_idamountstatus
U1O12000completed
U1O21500completed
U1O3800cancelled
U2O4500completed
U3O53000completed
U3O62500completed
U3O71000completed
U4O8300cancelled
Expected Output

Expected output (completed_amount descending):

user_idcompleted_orderscompleted_amount
U336500
U123500

U2 has one completed order (<2), and U4 has zero completed orders, so both are excluded. U1's cancelled O3 is excluded, leaving two completed orders totaling 3500.

Model Answer
SELECT
  user_id,
  COUNT(*) FILTER (WHERE status = 'completed')   AS completed_orders,  -- count only completed orders
  COALESCE(SUM(amount) FILTER (WHERE status = 'completed'), 0) AS completed_amount  -- sum completed amounts only; use 0 when none match
FROM orders
GROUP BY user_id
HAVING COUNT(*) FILTER (WHERE status = 'completed') >= 2   -- filter groups after aggregation
ORDER BY completed_amount DESC;

/*
  Execution order (SQL's logical evaluation order):
  1. FROM orders                                   → read 8 rows
  2. GROUP BY user_id                              → form four groups: U1, U2, U3, U4
  3. Aggregation (FILTER targets completed only)   → U1:2/3500, U2:1/500, U3:3/6500, U4:0/0
  4. HAVING completed_orders >= 2                  → exclude U2(1) and U4(0)
  5. SELECT / ORDER BY completed_amount DESC       → output in descending amount order
*/
Explanation (table transitions & key points)
SELECT user_id, COUNT(*) FILTER (WHERE status = 'completed') AS completed_orders, COALESCE(SUM(amount) FILTER (WHERE status = 'completed'), 0) AS completed_amount FROM orders GROUP BY user_id HAVING COUNT(*) FILTER (WHERE status = 'completed') >= 2 ORDER BY completed_amount DESC;
LEGEND
Rows read / loaded
① FROM orders (8 rows)
FROM ordersRead every order row. Completed and cancelled statuses are mixed, and cancelled orders must not be counted as sales.
1 / 5
user_idorder_idamountstatus
U1O12000completed
U1O21500completed
U1O3800cancelled
U2O4500completed
U3O53000completed
U3O62500completed
U3O71000completed
U4O8300cancelled
8 rows read
LEARNING POINTS
WHERE filters rows, while HAVING filters groups: WHERE filters rows before grouping, while HAVING filters aggregate values after grouping. Only HAVING can use an aggregate result as in HAVING COUNT(*) >= 2.
FILTER is a more readable alternative to CASE WHEN: The SUM(CASE WHEN ... THEN 1 ELSE 0 END) used in Q3 can be written in PostgreSQL as COUNT(*) FILTER (WHERE ...). Multiple conditional aggregates in one query remain easy to understand.
WHERE and HAVING can be used together: A standard practical pattern is WHERE order_date >= '2024-01-01' to filter rows by period first, followed by GROUP BY and HAVING SUM(amount) >= 10000 to keep only high-value groups.
ANTI-PATTERNS
Putting an aggregate condition in WHERE and getting an error: WHERE COUNT(*) >= 2 produces an error because aggregate functions cannot be used in WHERE. Put aggregate conditions in HAVING. Conversely, row-level conditions such as pre-filtering status should go in WHERE for better performance.
Aggregating purchase amounts while leaving cancelled rows included: Without FILTER, SUM(amount) mixes cancelled amounts into sales. Explicitly define what counts as sales with FILTER (or WHERE) and keep the definition consistent.
Practical column: Centralize segment definitions with HAVING
A segment definition such as “valuable customers = at least three completed orders and at least 50,000 in cumulative spend” can be represented directly by the HAVING condition as a living definition. In RFM analysis (Recency, Frequency, Monetary), HAVING is also the standard way to express thresholds for F (frequency) and M (monetary value). Move thresholds into variables at the beginning of the query or into dbt's var() so a definition change is made in one place and the whole team shares the same understanding of “valuable customer.”
QUESTION 10
Monthly Churn Rate — Find Churned Users with an Anti-Join (LEFT JOIN + IS NULL)
LEFT JOINAnti-JoinChurn RateIS NULLSelf-Join
Background

Churn is the reverse side of retention and a vital KPI for SaaS and subscription businesses. To find “users who were present last month but absent this month,” the standard pattern is an anti-join: LEFT JOIN and keep only rows with no matching row on the other side (IS NULL).

LEFT JOIN ... ON condition
WHERE right_table.key IS NULL   -- no match = extract the set difference
-- present in prev (last month) but absent from curr (this month) = churn
Where should outer-join filters go, ON or WHERE: Put the filter on the right table (curr), curr.month = '2024-04', in the ON clause. If you put it in WHERE, NULL rows fail NULL = '2024-04' and disappear, causing the LEFT JOIN to degenerate into an INNER JOIN and breaking the anti-join.
Problem

From monthly_active, which records monthly active users, identify users who were active in 2024-03 but inactive in 2024-04. Return user_id in ascending user_id order. Also confirm the churn rate in the explanation.

Tables used
► monthly_active (7 rows)
monthuser_id
2024-03U1
2024-03U2
2024-03U3
2024-03U4
2024-04U1
2024-04U3
2024-04U5

※ March={U1,U2,U3,U4}; April={U1,U3,U5}. U5 is new, not churned.

Expected Output

Expected output (churned users, user_id ascending):

user_id
U2
U4

U2 and U4 were present in March but absent in April, so they churned. Churn rate = 2 churned users ÷ 4 March active users = 50.00%.

Model Answer
SELECT prev.user_id                -- present in March but absent in April = churned user
FROM      monthly_active AS prev
LEFT JOIN monthly_active AS curr
  ON  curr.user_id = prev.user_id
  AND curr.month   = '2024-04'     -- put the right-table filter in ON
WHERE prev.month = '2024-03'
  AND curr.user_id IS NULL         -- no match = inactive in April
ORDER BY prev.user_id;

/*
  Execution order (SQL's logical evaluation order):
  1. FROM prev LEFT JOIN curr      → match the next month to prev (keep unmatched rows as NULL)
  2. WHERE prev.month               → limit to users in the reference month
  3. AND curr.user_id IS NULL       → keep only rows with no match in the next month = churn
  4. SELECT prev.user_id / ORDER BY  → output in ascending order

  -- Churn rate (extra):
  --   COUNT(*) FILTER (WHERE curr.user_id IS NULL) * 100.0 / COUNT(*)
  --   = 2 / 4 * 100 = 50.00 (denominator: March active users)
*/
Explanation (table transitions & key points)
SELECT prev.user_id FROM monthly_active AS prev LEFT JOIN monthly_active AS curr ON curr.user_id = prev.user_id AND curr.month = '2024-04' WHERE prev.month = '2024-03' AND curr.user_id IS NULL ORDER BY prev.user_id;
LEGEND
Rows read / loaded
① FROM monthly_active (7 rows)
FROM monthly_activeThese are all monthly-active records: U1–U4 in March and U1, U3, and U5 in April. The same table is self-joined in two roles, prev (last month) and curr (this month).
1 / 4
monthuser_id
2024-03U1
2024-03U2
2024-03U3
2024-03U4
2024-04U1
2024-04U3
2024-04U5
7 rows read
LEARNING POINTS
An anti-join is the standard way to take a set difference: Keep every row with LEFT JOIN, then use WHERE right.key IS NULL to extract only rows with no matching row. This gives the difference set “present in prev but absent in curr” and applies to churn, non-purchasers, missing records, and more.
Put right-table conditions in ON and left-table conditions in WHERE: Put curr.month='2024-04' in ON. The reference-side condition prev.month='2024-03' belongs in WHERE because prev is the left table whose rows are retained. This placement distinction is the heart of outer joins.
NOT EXISTS and NOT IN are alternatives: WHERE NOT EXISTS (SELECT 1 FROM monthly_active c WHERE c.user_id=prev.user_id AND c.month='2024-04') is equivalent and is often preferred in practice because it is readable and has fewer NULL traps. NOT IN becomes false for every row if the target side contains NULL, so NOT EXISTS is safer.
ANTI-PATTERNS
Put the right-table condition in WHERE and accidentally create an INNER JOIN: Writing WHERE curr.month='2024-04' makes the unmatched rows fail NULL='2024-04' and disappear. The anti-join then returns no churned users—a classic bug. Put the right-table filter in ON.
Mix new users into churn: U5 in April is new, not churned. The churn denominator is the active population in the previous month (March). Because prev is the reference side, U5 is naturally excluded; reversing the direction confuses new users with churned users.
Practical column: Churn rate and retention rate are two sides of the same coin
Churn rate + retention rate = 100% when measured for the same cohort and period. This quiz's 50% churn means 50% retention. In SaaS, even a 5% monthly churn rate means that about 46% will leave over a year when compounded, so the effect accumulates. Tracking reactivated users—people who cancelled but returned the month after next—also reveals recovery that a simple monthly churn rate cannot show. Monitor user inflow and outflow from both sides by pairing this with Q4's retention (survival) measure.