
You're three months into a new role as a senior analyst at a SaaS company. The CEO walks into your Monday standup and asks a deceptively simple question: "Are we retaining customers better than we were a year ago?" You pull up the dashboard your predecessor built. It shows monthly active users, average revenue per account, and a churn rate — all aggregated, all context-free. The numbers look fine. But you can't actually answer the question, because aggregated metrics lie. A rising MAU number can mask a catastrophic retention problem if new acquisition is outpacing the bleeding. The churn rate tells you nothing about which customers are churning, when they're churning, or whether things are getting better or worse cohort over cohort.
Cohort analysis is the antidote. By grouping customers according to when they first appeared — their acquisition cohort — and then tracking their behavior over subsequent time periods, you can see retention curves, measure lifetime value trajectories, and identify precisely when and where customers fall away. The problem is that cohort analysis in DAX is genuinely hard. It requires you to hold two different time contexts simultaneously: the cohort's origin period and the observation period. Standard time intelligence functions weren't designed for this. You need GENERATE, careful use of CALCULATETABLE, and a solid understanding of how filter context propagates across a virtual table that spans two date axes.
By the end of this lesson, you'll be able to build a production-grade cohort analysis system in Power BI entirely in DAX — no Python preprocessing, no SQL views required. You'll understand the data modeling decisions that make or break this kind of analysis, and you'll have measures for cohort retention rates, cumulative churn, and discounted lifetime value that you can adapt to any subscription or transactional business.
What you'll learn:
GENERATE to create period-offset virtual tables for retention calculationsThis is an expert-level lesson. You should be comfortable with:
CALCULATE does to eachVAR/RETURN) for readability and performanceSUMX, AVERAGEX, MAXXDATEADD, DATESYTD, SAMEPERIODLASTYEARIf any of those feel shaky, revisit those topics first. Cohort DAX is not the place to learn filter context for the first time.
Before writing a single DAX measure, you need to think carefully about your data model. This is where most cohort analysis projects go wrong — they try to bolt cohort logic onto a model designed for standard reporting, and then compensate with increasingly complex DAX. Don't do that.
Assume you're working with a SaaS subscription business. Your central fact table — let's call it Subscriptions — contains one row per customer per billing period. Here's a representative schema:
Subscriptions
-----------
CustomerID (text)
PeriodStart (date) — first day of the billing period
PeriodEnd (date) — last day of the billing period
MRR (decimal) — monthly recurring revenue
Status (text) — "Active", "Churned", "Paused"
ProductTier (text) — "Starter", "Growth", "Enterprise"
The key insight is that PeriodStart is the date you'll use for calendar-time filtering — it tells you when a subscription was active. You need a separate piece of information: when did this customer first appear? That's the cohort date.
You have two options for storing cohort dates: compute them on the fly in DAX, or persist them as a computed column in the model. For performance at scale, persist them.
Add a calculated column to Subscriptions:
Subscriptions[CohortMonth] =
VAR CustomerID = Subscriptions[CustomerID]
VAR FirstActivity =
CALCULATE(
MIN(Subscriptions[PeriodStart]),
ALL(Subscriptions),
Subscriptions[CustomerID] = CustomerID
)
RETURN
DATE(YEAR(FirstActivity), MONTH(FirstActivity), 1)
This gives you the first day of the month in which each customer first appeared. Every row for a given customer gets the same CohortMonth value. Note the ALL(Subscriptions) — you must remove the row-level filter context that the calculated column evaluation creates, otherwise you'd just get the PeriodStart of the current row.
Warning: Using
ALLEXCEPThere might seem cleaner, but be careful.CALCULATEin a calculated column already establishes row context-to-filter context transition. TheALL(Subscriptions)removes the current row's filter, and then you re-apply just theCustomerIDfilter manually. This is intentional and correct.
Here's the architectural insight that unlocks everything else: cohort analysis requires two date axes — a cohort date axis and a calendar date axis. You need two separate Date tables.
DateTable_Cohort — used to filter by cohort month
DateTable_Calendar — used to filter by observation period
Both are standard date dimension tables. Both connect to Subscriptions, but on different keys:
DateTable_Cohort[Date] → Subscriptions[CohortMonth] (many-to-one)DateTable_Calendar[Date] → Subscriptions[PeriodStart] (many-to-one)In Power BI's model view, create these as two separate calculated tables derived from CALENDARAUTO() or a CALENDAR() function, then rename them appropriately. Set DateTable_Calendar as your primary date table (mark as date table in Power BI). Leave DateTable_Cohort as a non-marked date table — you'll reference it by name in DAX, not via automatic time intelligence.
This two-table model means your visuals can have DateTable_Cohort[Year Month] on one axis (the cohort rows) and a period offset on the other axis (Month 0, Month 1, Month 2...), which is exactly what a cohort heatmap needs.
The third element you need is a period offset table — a simple integer table from 0 to N representing "months since cohort start":
PeriodOffset =
GENERATESERIES(0, 24, 1)
This creates a single-column table with values 0 through 24. Rename the column MonthOffset. This table has no relationship to anything — it floats free. Your measures will use it as a slicer/axis context through SELECTEDVALUE or VALUES.
GENERATE is underused and misunderstood. It's DAX's version of a correlated subquery or a lateral join in SQL. Its signature is:
GENERATE(<table1>, <table2>)
It iterates over every row of <table1> and, for each row, evaluates <table2> in the row context of that row. The result is the cross join of those pairs, but critically, <table2> can reference columns from <table1> — making it a correlated cross join, not a flat cartesian product.
Here's the key difference from CROSSJOIN:
-- CROSSJOIN: table2 is static, evaluated once
CROSSJOIN(Customers, PeriodOffset)
-- GENERATE: table2 is evaluated per row of table1
GENERATE(
Customers,
FILTER(
Subscriptions,
Subscriptions[CustomerID] = Customers[CustomerID]
)
)
In the GENERATE version, Customers[CustomerID] on the right side refers to the current row of the left side's iteration. That's the power. You can use GENERATE to build a virtual table of (Customer, ActivePeriod) pairs — exactly what cohort retention needs.
GENERATEALL behaves like GENERATE but returns all rows from <table1> even when <table2> returns an empty table for a given row — a left outer join vs inner join distinction. For cohort analysis, you'll typically want GENERATE (inner join semantics) because you only care about cohorts that have observable data. Use GENERATEALL when you're explicitly tracking zero-activity periods and need those gaps to appear as zeroes rather than disappear entirely.
Let's build up the retention measure in layers. Start simple and add complexity.
The denominator of every retention rate is the number of customers who entered the cohort. Month 0 is the acquisition month:
Cohort Size =
VAR CohortDate =
SELECTEDVALUE(DateTable_Cohort[Date])
RETURN
IF(
ISBLANK(CohortDate),
BLANK(),
CALCULATE(
DISTINCTCOUNT(Subscriptions[CustomerID]),
DateTable_Cohort[Date] = CohortDate,
Subscriptions[PeriodStart] = CohortDate
)
)
Wait — that's not quite right. The condition Subscriptions[PeriodStart] = CohortDate would only count customers whose first active period started exactly on the cohort month. Since CohortMonth is always the first of the month and PeriodStart is also normalized to the first of the month, this works for monthly billing. But be explicit:
Cohort Size =
VAR SelectedCohort =
SELECTEDVALUE(DateTable_Cohort[Date])
RETURN
IF(
ISBLANK(SelectedCohort),
BLANK(),
CALCULATE(
DISTINCTCOUNT(Subscriptions[CustomerID]),
Subscriptions[CohortMonth] = SelectedCohort,
Subscriptions[PeriodStart] = SelectedCohort
)
)
The two filters work together: CohortMonth = SelectedCohort ensures we're looking at the right cohort, and PeriodStart = SelectedCohort ensures we're only counting customers who were active in their first month (Month 0). Both conditions must be true simultaneously.
Now the harder part: how many customers from cohort C were still active N months later?
Retained Customers =
VAR SelectedCohort =
SELECTEDVALUE(DateTable_Cohort[Date])
VAR SelectedOffset =
SELECTEDVALUE(PeriodOffset[MonthOffset])
VAR ObservationPeriod =
DATE(
YEAR(SelectedCohort),
MONTH(SelectedCohort) + SelectedOffset,
1
)
RETURN
IF(
ISBLANK(SelectedCohort) || ISBLANK(SelectedOffset),
BLANK(),
CALCULATE(
DISTINCTCOUNT(Subscriptions[CustomerID]),
Subscriptions[CohortMonth] = SelectedCohort,
Subscriptions[PeriodStart] = ObservationPeriod,
Subscriptions[Status] = "Active"
)
)
ObservationPeriod is the calendar date that is SelectedOffset months after the cohort start. The DATE function handles month arithmetic, rolling over years correctly (Month 13 of 2023 becomes January 2024, for instance).
Tip: The
DATE(YEAR, MONTH + N, 1)pattern is safer thanDATEADDhere becauseDATEADDrequires a relationship to a date table to function, and yourPeriodOffsettable has no such relationship. The pureDATEarithmetic works regardless.
Retention Rate =
VAR SelectedCohort =
SELECTEDVALUE(DateTable_Cohort[Date])
VAR SelectedOffset =
SELECTEDVALUE(PeriodOffset[MonthOffset])
VAR ObservationPeriod =
DATE(
YEAR(SelectedCohort),
MONTH(SelectedCohort) + SelectedOffset,
1
)
VAR CohortSize =
CALCULATE(
DISTINCTCOUNT(Subscriptions[CustomerID]),
Subscriptions[CohortMonth] = SelectedCohort,
Subscriptions[PeriodStart] = SelectedCohort
)
VAR RetainedCount =
CALCULATE(
DISTINCTCOUNT(Subscriptions[CustomerID]),
Subscriptions[CohortMonth] = SelectedCohort,
Subscriptions[PeriodStart] = ObservationPeriod,
Subscriptions[Status] = "Active"
)
RETURN
IF(
CohortSize = 0 || ISBLANK(SelectedCohort) || ISBLANK(SelectedOffset),
BLANK(),
DIVIDE(RetainedCount, CohortSize)
)
Format this measure as a percentage. This is the number that goes into each cell of your cohort heatmap.
The cohort heatmap — sometimes called the retention triangle — is a matrix visual where:
In Power BI, set up a Matrix visual with:
DateTable_Cohort[Year Month] (a calculated column formatted as "MMM YYYY")PeriodOffset[MonthOffset][Retention Rate]The "triangle" shape emerges naturally: recent cohorts don't yet have data for high offset periods, so those cells return BLANK() and appear empty — which is exactly right. You're not looking at missing data; you're looking at the future. The BLANK() return in your measure when ObservationPeriod is in the future is a feature, not a bug.
For conditional formatting, apply a background color rule on [Retention Rate] using a diverging color scale — dark green at 100%, white at 50%, red at 0%. This turns the heatmap into an immediately readable diagnostic tool.
Retention and churn are two sides of the same coin, but they answer different questions. Retention asks "who stayed?" Churn asks "who left, and when?" For planning and intervention, the timing of churn matters enormously — churn in Month 1 is usually an onboarding problem; churn in Month 12 is a value/renewal problem.
New Churns in Period =
VAR SelectedCohort =
SELECTEDVALUE(DateTable_Cohort[Date])
VAR SelectedOffset =
SELECTEDVALUE(PeriodOffset[MonthOffset])
-- The period BEFORE the observation period
VAR PriorPeriod =
DATE(
YEAR(SelectedCohort),
MONTH(SelectedCohort) + SelectedOffset - 1,
1
)
VAR CurrentPeriod =
DATE(
YEAR(SelectedCohort),
MONTH(SelectedCohort) + SelectedOffset,
1
)
-- Customers active in the prior period
VAR ActivePrior =
CALCULATETABLE(
VALUES(Subscriptions[CustomerID]),
Subscriptions[CohortMonth] = SelectedCohort,
Subscriptions[PeriodStart] = PriorPeriod,
Subscriptions[Status] = "Active"
)
-- Customers active in the current period
VAR ActiveCurrent =
CALCULATETABLE(
VALUES(Subscriptions[CustomerID]),
Subscriptions[CohortMonth] = SelectedCohort,
Subscriptions[PeriodStart] = CurrentPeriod,
Subscriptions[Status] = "Active"
)
-- Churned = were active prior, not active current
VAR ChurnedCustomers =
EXCEPT(ActivePrior, ActiveCurrent)
RETURN
IF(
ISBLANK(SelectedCohort) || SelectedOffset = 0,
BLANK(),
COUNTROWS(ChurnedCustomers)
)
This uses EXCEPT — one of DAX's set operation functions — to find the set difference between prior-period actives and current-period actives. The result is the customers who were there and then weren't.
Important:
EXCEPTperforms a column-by-column comparison. Both tables fed toEXCEPTmust have the same column structure. Since bothActivePriorandActiveCurrentareVALUES(Subscriptions[CustomerID])— a single-column table — the comparison works cleanly.
Cumulative churn at period N tells you what fraction of the original cohort has churned by month N:
Cumulative Churn Rate =
VAR SelectedCohort =
SELECTEDVALUE(DateTable_Cohort[Date])
VAR SelectedOffset =
SELECTEDVALUE(PeriodOffset[MonthOffset])
VAR CurrentPeriod =
DATE(
YEAR(SelectedCohort),
MONTH(SelectedCohort) + SelectedOffset,
1
)
VAR CohortSize =
CALCULATE(
DISTINCTCOUNT(Subscriptions[CustomerID]),
Subscriptions[CohortMonth] = SelectedCohort,
Subscriptions[PeriodStart] = SelectedCohort
)
VAR CurrentlyActive =
CALCULATE(
DISTINCTCOUNT(Subscriptions[CustomerID]),
Subscriptions[CohortMonth] = SelectedCohort,
Subscriptions[PeriodStart] = CurrentPeriod,
Subscriptions[Status] = "Active"
)
RETURN
IF(
CohortSize = 0 || ISBLANK(SelectedCohort),
BLANK(),
DIVIDE(CohortSize - CurrentlyActive, CohortSize)
)
This is simply 1 minus the retention rate, but it's worth keeping as a separate measure because some stakeholders want to see churn accumulation explicitly, not infer it from retention.
Now we get to the part that separates competent analysts from expert ones. Cohort lifetime value isn't just "sum of all revenue from customers in a cohort." It's a time-series calculation that requires you to sum revenue across all observation periods for each customer in the cohort — and if you want present-value LTV, apply a discount rate to each period's revenue before summing.
Start with the undiscounted version:
Cohort Cumulative LTV =
VAR SelectedCohort =
SELECTEDVALUE(DateTable_Cohort[Date])
VAR SelectedOffset =
SELECTEDVALUE(PeriodOffset[MonthOffset])
VAR CohortSize =
CALCULATE(
DISTINCTCOUNT(Subscriptions[CustomerID]),
Subscriptions[CohortMonth] = SelectedCohort,
Subscriptions[PeriodStart] = SelectedCohort
)
VAR MaxObservationPeriod =
DATE(
YEAR(SelectedCohort),
MONTH(SelectedCohort) + SelectedOffset,
1
)
VAR CumulativeRevenue =
CALCULATE(
SUM(Subscriptions[MRR]),
Subscriptions[CohortMonth] = SelectedCohort,
Subscriptions[PeriodStart] <= MaxObservationPeriod,
Subscriptions[Status] = "Active"
)
RETURN
IF(
CohortSize = 0 || ISBLANK(SelectedCohort),
BLANK(),
DIVIDE(CumulativeRevenue, CohortSize)
)
This gives you average cumulative revenue per customer in the cohort up through the observation period. It's per-customer (divided by cohort size) because absolute revenue grows with cohort size — you want a normalized metric that's comparable across cohorts of different sizes.
Present-value LTV applies a discount rate to future revenue, reflecting that a dollar today is worth more than a dollar next year. This requires you to iterate over each period offset, compute revenue for that period, discount it, and sum. This is where GENERATE shines:
Cohort Discounted LTV =
VAR SelectedCohort =
SELECTEDVALUE(DateTable_Cohort[Date])
VAR SelectedOffset =
SELECTEDVALUE(PeriodOffset[MonthOffset])
VAR AnnualDiscountRate = 0.12 -- 12% annual discount rate
VAR MonthlyDiscountRate = (1 + AnnualDiscountRate) ^ (1/12) - 1
VAR CohortSize =
CALCULATE(
DISTINCTCOUNT(Subscriptions[CustomerID]),
Subscriptions[CohortMonth] = SelectedCohort,
Subscriptions[PeriodStart] = SelectedCohort
)
-- Build a virtual table: one row per month offset from 0 to SelectedOffset
VAR OffsetTable =
GENERATESERIES(0, SelectedOffset, 1)
-- For each offset, compute discounted MRR
VAR DiscountedRevenue =
SUMX(
OffsetTable,
VAR ThisOffset = [Value]
VAR ThisPeriod =
DATE(
YEAR(SelectedCohort),
MONTH(SelectedCohort) + ThisOffset,
1
)
VAR PeriodRevenue =
CALCULATE(
SUM(Subscriptions[MRR]),
Subscriptions[CohortMonth] = SelectedCohort,
Subscriptions[PeriodStart] = ThisPeriod,
Subscriptions[Status] = "Active"
)
VAR DiscountFactor = 1 / (1 + MonthlyDiscountRate) ^ ThisOffset
RETURN
PeriodRevenue * DiscountFactor
)
RETURN
IF(
CohortSize = 0 || ISBLANK(SelectedCohort),
BLANK(),
DIVIDE(DiscountedRevenue, CohortSize)
)
Let's unpack the SUMX block. GENERATESERIES(0, SelectedOffset, 1) creates a virtual single-column table with values 0, 1, 2, ..., N. SUMX iterates over that table, and for each row, [Value] refers to the current integer in that column. We use that integer to construct ThisPeriod (the actual calendar date for that observation), pull the MRR for the cohort in that period, multiply by the discount factor for that number of months in the future, and sum the whole thing up.
The discount factor 1 / (1 + r)^t is standard discounted cash flow math. At Month 0, the factor is 1. At Month 12, it's approximately 0.887 for a 12% annual rate.
Performance Note: This measure contains a
CALCULATEinside aSUMXloop. Every iteration fires a new storage engine query. For 24 months of offsets and dozens of cohorts in a single visual, that's potentially thousands of storage engine calls. If performance is degraded, consider materializing the discounted revenue by period as a calculated column instead — trade-off flexibility for speed.
For more sophisticated LTV analysis — particularly if you want to slice by customer attributes that weren't known at cohort time (e.g., what's the LTV of Enterprise customers who started in Q1 2023?) — you need to build a bridge table that links each customer to their cohort. GENERATE is the cleanest way to do this in DAX:
CohortCustomerBridge =
GENERATE(
SUMMARIZE(
Subscriptions,
Subscriptions[CustomerID],
Subscriptions[CohortMonth]
),
VAR ThisCustomer = Subscriptions[CustomerID]
VAR ThisCohort = Subscriptions[CohortMonth]
RETURN
CALCULATETABLE(
ADDCOLUMNS(
VALUES(Subscriptions[PeriodStart]),
"MRR",
CALCULATE(
SUM(Subscriptions[MRR]),
Subscriptions[CustomerID] = ThisCustomer
),
"MonthOffset",
DATEDIFF(
ThisCohort,
Subscriptions[PeriodStart],
MONTH
)
),
Subscriptions[CustomerID] = ThisCustomer,
Subscriptions[CohortMonth] = ThisCohort
)
)
This creates a calculated table (store it as a named table in your model, not a measure) that has one row per customer per billing period, enriched with the customer's cohort month and the month offset for each period. This bridge table can then have relationships to your customer dimension, enabling cohort analysis sliced by customer attributes. It's a more expensive model artifact — update it carefully in large datasets — but it's the most flexible foundation for complex LTV slicing.
A common requirement is to compare multiple cohorts on the same line chart: "Show me the Month 0–12 retention curve for Q1 2023, Q2 2023, and Q3 2023 cohorts on the same axes." This requires your retention measures to behave correctly when multiple cohorts are selected simultaneously via a slicer.
The trap is that SELECTEDVALUE returns BLANK() when multiple values are selected. You need ALLSELECTED instead:
Multi-Cohort Retention Rate =
VAR SelectedOffset =
SELECTEDVALUE(PeriodOffset[MonthOffset])
-- For each cohort in context, compute its cohort size and retained count
VAR RetentionTable =
ADDCOLUMNS(
ALLSELECTED(DateTable_Cohort[Date]),
"CohortSize",
CALCULATE(
DISTINCTCOUNT(Subscriptions[CustomerID]),
USERELATIONSHIP(DateTable_Cohort[Date], Subscriptions[CohortMonth])
),
"RetainedCount",
VAR ThisCohort = DateTable_Cohort[Date]
VAR ObsDate =
DATE(YEAR(ThisCohort), MONTH(ThisCohort) + SelectedOffset, 1)
RETURN
CALCULATE(
DISTINCTCOUNT(Subscriptions[CustomerID]),
Subscriptions[CohortMonth] = ThisCohort,
Subscriptions[PeriodStart] = ObsDate,
Subscriptions[Status] = "Active"
)
)
RETURN
DIVIDE(
SUMX(RetentionTable, [RetainedCount]),
SUMX(RetentionTable, [CohortSize])
)
Warning about USERELATIONSHIP: In the
ADDCOLUMNSblock above,USERELATIONSHIPactivates the relationship betweenDateTable_Cohort[Date]andSubscriptions[CohortMonth]. This is necessary because when you're insideADDCOLUMNSiterating overDateTable_Cohort[Date], the relationship filter from the date table to the fact table isn't automatically active unless you either useUSERELATIONSHIPor explicitly filter using column comparisons. Test this carefully in your specific model — behavior can vary based on whether your relationships are active or inactive.
Here's a structured exercise to consolidate everything. Build this from scratch using the Power BI Desktop file you'll create.
Create the following three tables in Power BI using Enter Data:
Subscriptions (sample — you'll want at least 200 rows for meaningful patterns): Create a table with columns: CustomerID (C001–C050), PeriodStart (date, first of month, ranging from Jan 2022 to Dec 2023), MRR (values between 99 and 999), Status ("Active" or "Churned"), ProductTier ("Starter", "Growth", "Enterprise").
Simulate realistic churn: customers should gradually drop off, with higher early-period churn and lower later-period churn for survived customers.
DateTable_Cohort: CALENDARAUTO() output, filtered to your data range.
DateTable_Calendar: Same, separately instantiated.
PeriodOffset: GENERATESERIES(0, 23, 1) — 0 to 23 months.
Add a CohortMonth calculated column to Subscriptions using the formula from earlier.
Build [Cohort Size] and validate: place it in a matrix with DateTable_Cohort[Year Month] on rows and confirm each cohort's size matches what you'd expect from your raw data.
Build [Retention Rate] and place it in a matrix with cohort months on rows and PeriodOffset[MonthOffset] on columns. You should see a triangle of values (lower-right corner empty for recent cohorts).
Apply conditional background color formatting to the matrix. Go to Format > Cell elements > Background color > Field value, and use [Retention Rate] as the field with a color scale.
Add [New Churns in Period] to a second matrix — confirm that [Cohort Size] at Month 0 minus cumulative [New Churns in Period] equals [Retained Customers] for each cohort/period combination.
Build [Cohort Cumulative LTV] and add it to the matrix. Observe how LTV grows across months — does it flatten as churn accelerates? That's the LTV curve you'd use for pricing and payback period analysis.
Finally, build [Cohort Discounted LTV] and compare it to the undiscounted version. The gap between them widens as the offset increases — this is the present-value haircut applied to future revenue.
For your validation, pick one cohort manually. Open the Subscriptions table, filter to that cohort's CohortMonth, and count unique CustomerIDs in Month 0. That should match [Cohort Size]. Then filter to Month 3 and count unique Active CustomerIDs. That should match [Retained Customers] at offset 3. If they match, your measures are correct.
Symptom: Your cohort sizes add up to more customers than you actually have.
Cause: A customer resubscribed after churning. When they resubscribed, they may have gotten a new PeriodStart that doesn't match their original CohortMonth, but your CohortMonth calculated column only looks at MIN(PeriodStart) per customer. If they churned, then resubscribed after a gap, the resubscription period rows will still have the original cohort month — which is usually correct behavior. But if your business tracks resubscriptions as new customers (new CustomerIDs), then a single human might appear in multiple cohorts. Clarify your business rules before writing DAX.
Symptom: Your measures return blank everywhere in the matrix.
Cause: Either the PeriodOffset table isn't on the visual's columns, or the DateTable_Cohort isn't on the visual's rows. SELECTEDVALUE requires exactly one value in context — if there are zero (table not on visual) or more than one (multiple selections), it returns blank.
Fix: Verify the visual field wells. Confirm PeriodOffset[MonthOffset] is in Columns and DateTable_Cohort[Date] (or a month-year column derived from it) is in Rows. If this is a card or a chart rather than a matrix, you need the ALLSELECTED pattern instead of SELECTEDVALUE.
Symptom: Retention at Month 13 looks identical to Month 1 retention, or shows obviously wrong values.
Cause: MONTH(SelectedCohort) + SelectedOffset overflows past 12 without year rollover — wait, actually DATE() does handle this correctly in DAX. The issue is more likely that your data doesn't contain rows for those periods (the subscription table doesn't have future rows), so the CALCULATE returns 0 or blank. Check whether the issue is in the measure logic or in your source data's date coverage.
Fix: Add a diagnostic measure: Debug ObsDate = DATE(YEAR(SELECTEDVALUE(DateTable_Cohort[Date])), MONTH(SELECTEDVALUE(DateTable_Cohort[Date])) + SELECTEDVALUE(PeriodOffset[MonthOffset]), 1). Place it alongside your retention measure in the matrix. You'll immediately see whether the date arithmetic is producing sensible values.
Symptom: Your CohortCustomerBridge calculated table takes minutes to refresh and produces tens of millions of rows.
Cause: The GENERATE pattern in a calculated table iterates over every row of the left table and materializes the correlated right side. For large datasets, this is extremely expensive.
Fix: Either materialize the table in your data warehouse (SQL or dbt) instead of in DAX, or narrow the scope — filter SUMMARIZE to only recent cohorts, or limit the offset range. Calculated tables in DAX are fully evaluated at refresh time and stored in the model's in-memory storage — they're not lazy. Plan accordingly.
Symptom: Some cohorts show Month 0 retention below 100%.
Cause: Your [Retained Customers] measure at offset 0 filters by Status = "Active", but some customers in the cohort were already marked "Churned" in their first month (e.g., a free trial that never converted, or a same-month churn). This is actually correct if your business rules define cohorts as all customers who started, including those who churned immediately.
Fix: Decide whether your cohorts should be "all customers who started" or "all customers who were active at end of Month 0." Adjust both the cohort size and the retention numerator to match the same definition. Inconsistency between cohort definition and retention definition is the most common source of nonsensical retention rates.
Symptom: Retention rates are identical for all cohorts — the cohort filter isn't doing anything.
Cause: If your DateTable_Cohort has an active relationship to Subscriptions[CohortMonth] but you're also filtering Subscriptions[CohortMonth] directly in your CALCULATE, you might inadvertently be overriding one with the other. Or, if the relationship is inactive and you forgot to use USERELATIONSHIP, the cohort filter from the visual is having no effect.
Fix: Be explicit. In measures that should be cohort-aware, always explicitly filter Subscriptions[CohortMonth] = SelectedCohort rather than relying on relationship propagation. Relationship-based filtering is implicit and can surprise you in complex models. Explicit CALCULATE filters are predictable.
If you're deploying cohort analysis for hundreds of thousands of customers and multi-year history, a few additional architectural decisions matter:
Rather than computing DISTINCTCOUNT(Subscriptions[CustomerID]) at query time across millions of rows, pre-aggregate a cohort summary table in your data warehouse:
CohortSummary
-----------
CohortMonth (date)
MonthOffset (integer)
CustomersAtStart (integer)
ActiveCustomers (integer)
MRR (decimal)
This table has one row per cohort per period — a manageable size. Your DAX becomes simple lookups rather than heavy aggregations:
Retention Rate (Pre-Aggregated) =
DIVIDE(
CALCULATE(SUM(CohortSummary[ActiveCustomers])),
CALCULATE(SUM(CohortSummary[CustomersAtStart]))
)
This is orders of magnitude faster and is the pattern you should use in production models with significant data volumes.
If you're using incremental refresh on your Subscriptions table, be aware that calculated columns (like CohortMonth) are re-evaluated only for the refreshed partitions, not the historical ones. If you rely on the MIN(PeriodStart) across all history in that calculated column, the historical partitions won't have their CohortMonth updated when new data arrives. This is a significant correctness risk.
Solution: Compute CohortMonth in your data source (SQL, Power Query) before the data lands in Power BI. Never compute cohort assignment logic in a DAX calculated column in an incrementally-refreshed model.
You've now built a complete cohort analysis system in DAX. Let's review what that actually means architecturally:
You have a two-date-axis model that separates cohort time from calendar time, allowing your visuals to display both axes independently. Your PeriodOffset floating table provides the integer axis for period-since-cohort, driven by SELECTEDVALUE in your measures. Your retention measures correctly isolate filter context using explicit CALCULATE filters rather than relying on relationship propagation. Your churn measures use EXCEPT to identify set differences between consecutive periods. Your LTV measures use SUMX over GENERATESERIES to iterate across periods and apply present-value discounting.
The GENERATE function sits at the heart of the more complex patterns — the CohortCustomerBridge table and any measure that needs to iterate over a correlated set of periods per cohort. Understanding GENERATE as "correlated cross join" is the mental model that will serve you in any advanced DAX scenario, not just cohort analysis.
Where to go from here:
Expand to weekly cohorts for consumer apps where weekly retention is more meaningful than monthly. The date arithmetic changes (DATEADD with WEEK interval, or [PeriodStart] + 7 * offset), but the structure is identical.
Add segmentation dimensions — build your retention matrix with a product tier slicer and watch how Enterprise retention diverges from Starter retention. This is where cohort analysis becomes genuinely actionable.
Model win-back / reactivation — customers who churn and return are a distinct behavioral segment. Extend your churn logic to detect gaps in activity and classify customers as "reactivated" vs. "new." EXCEPT and INTERSECT together handle this elegantly.
Build a leading indicator model — use early cohort behavior (Month 1 and Month 2 retention) to predict long-term LTV. This requires DAX statistical functions (CORR-style calculations via SUMX) or a handoff to Python/R in Power BI's analytics visuals.
Automate with DAX Studio — use DAX Studio's Server Timings to profile exactly which measures are slow, and use its query plan view to understand whether storage engine or formula engine is the bottleneck in your cohort calculations.
Cohort analysis in DAX is one of the most challenging and most valuable skills a Power BI developer can have. The models you've built in this lesson are not academic exercises — they're the exact patterns used at software companies to make pricing decisions, evaluate onboarding changes, and set retention targets. Take them, adapt them, and build something real.