What a cohort retention analysis actually answers

A single retention number — "we retain 40% of users" — hides everything useful. It doesn't say retained from when, or whether retention is getting better or worse for newer users. Cohort analysis fixes that by splitting users into groups based on when they joined, then tracking each group separately over time. That's what turns "40% retention" into "cohorts from March are retaining worse than cohorts from January — something changed in February."

The output is a grid: rows are cohorts (signup month), columns are periods since signup (Month 0, Month 1, Month 2…), and each cell is the percentage of that cohort still active in that period. Reading down a column tells you if retention is improving cohort-over-cohort. Reading across a row tells you how a single cohort decays over time.

The two things you need: a cohort date and an activity date

Every cohort analysis needs exactly two date columns, whether they live in one table or two: a cohort-defining event (almost always signup) and a stream of activity events (logins, orders, sessions — whatever "retained" means for your product). Here's a minimal two-table setup used throughout this article:

TableColumnPurpose
usersuser_idUnique user identifier
signup_atDefines the cohort
eventsuser_idLinks activity back to a user
event_atDefines whether the user was "active" in a given period

Step 1 — Assign each user to a cohort

Truncate signup_at down to a month (or week) so every user who joined in the same period shares the exact same cohort value:

step1-cohorts.sql
SELECT
  user_id,
  DATE_TRUNC('month', signup_at) AS cohort_month
FROM users;

Two users who sign up on March 3rd and March 29th both get cohort_month = 2026-03-01. This is the anchor everything else measures against, and it's why raw timestamps are useless here — no two signups happen at the exact same second, so nothing would ever group.

Step 2 — Calculate the period offset

For every activity event, work out how many periods have passed since that user's cohort month. This is the join that connects "what a user did" to "how long after they joined they did it":

step2-offsets.sql
WITH cohorts AS (
  SELECT user_id, DATE_TRUNC('month', signup_at) AS cohort_month
  FROM users
)
SELECT
  c.user_id,
  c.cohort_month,
  DATE_TRUNC('month', e.event_at) AS activity_month,
  DATEDIFF('month', c.cohort_month, DATE_TRUNC('month', e.event_at)) AS period_number
FROM cohorts c
JOIN events e ON e.user_id = c.user_id;

period_number = 0 is the cohort's signup month itself (they're trivially "active" — they just joined). period_number = 1 means active exactly one month after joining, and so on. DATEDIFF's exact syntax varies by database — see the dialect notes below.

Step 3 — Aggregate into the cohort grid

Count distinct active users per cohort, per period. DISTINCT matters here — a user with ten events in the same month should count once, not ten times:

step3-aggregate.sql
SELECT
  cohort_month,
  period_number,
  COUNT(DISTINCT user_id) AS active_users
FROM offsets
GROUP BY cohort_month, period_number
ORDER BY cohort_month, period_number;

The full query, start to finish

Chained together as CTEs, the whole analysis is one query — no procedural loop, no app-layer post-processing:

cohort-retention-full.sql
WITH cohorts AS (
  SELECT user_id, DATE_TRUNC('month', signup_at) AS cohort_month
  FROM users
),

offsets AS (
  SELECT
    c.user_id,
    c.cohort_month,
    DATEDIFF('month', c.cohort_month, DATE_TRUNC('month', e.event_at)) AS period_number
  FROM cohorts c
  JOIN events e ON e.user_id = c.user_id
  WHERE e.event_at >= c.cohort_month
),

cohort_size AS (
  SELECT cohort_month, COUNT(DISTINCT user_id) AS total_users
  FROM cohorts
  GROUP BY cohort_month
),

active_by_period AS (
  SELECT cohort_month, period_number, COUNT(DISTINCT user_id) AS active_users
  FROM offsets
  GROUP BY cohort_month, period_number
)

SELECT
  a.cohort_month,
  a.period_number,
  a.active_users,
  s.total_users,
  ROUND(a.active_users * 100.0 / s.total_users, 1) AS retention_pct
FROM active_by_period a
JOIN cohort_size s ON s.cohort_month = a.cohort_month
ORDER BY a.cohort_month, a.period_number;
cohort_monthperiod_numberactive_userstotal_usersretention_pct
2026-01-010500500100.0
2026-01-01122050044.0
2026-01-01216550033.0
2026-02-010640640100.0
2026-02-01130164047.0
Why the WHERE e.event_at >= c.cohort_month filter matters: without it, backdated or test events from before a user's signup month can produce a negative period_number, which silently corrupts the grid.

Turning raw counts into retention percentages

The query above joins back to a separate cohort_size CTE to get period-0 totals, which is the clearest approach to read and debug. If you'd rather avoid the extra join, a window function does it inline:

window-function-alt.sql
SELECT
  cohort_month,
  period_number,
  active_users,
  ROUND(
    active_users * 100.0 /
    FIRST_VALUE(active_users) OVER (
      PARTITION BY cohort_month ORDER BY period_number
    ),
  1) AS retention_pct
FROM active_by_period;

This works because period 0 is, by construction, always the first row per cohort when ordered by period_number — so FIRST_VALUE always returns the cohort's total size. If you're not yet comfortable with window function partitioning, PARTITION BY Explained in SQL covers exactly this pattern.

Pivoting rows into the triangle shape

The query above returns one row per cohort-period pair — correct, but not the "triangle" shape people expect from a cohort chart. Getting there is a pivot, done with conditional aggregation so it works identically across databases without a dialect-specific PIVOT operator:

pivot-triangle.sql
SELECT
  cohort_month,
  MAX(CASE WHEN period_number = 0 THEN retention_pct END) AS m0,
  MAX(CASE WHEN period_number = 1 THEN retention_pct END) AS m1,
  MAX(CASE WHEN period_number = 2 THEN retention_pct END) AS m2,
  MAX(CASE WHEN period_number = 3 THEN retention_pct END) AS m3
FROM cohort_retention
GROUP BY cohort_month
ORDER BY cohort_month;

Each MAX(CASE WHEN ...) pair picks out one period and spreads it into its own column — the same conditional-aggregation trick used to hand-roll a pivot table. Most BI tools (and spreadsheet exports) can also do this last step for you, so it's common to stop at the row-per-period version and let the visualization layer handle the pivot.

Weekly vs. monthly cohorts

Monthly cohortsWeekly cohorts
Best forB2B SaaS, subscriptions, longer usage cyclesConsumer apps, high-frequency products
Noise levelLower — smooths day-to-day varianceHigher — but catches early drop-off faster
Query change neededDATE_TRUNC('month', ...)DATE_TRUNC('week', ...)
Time to first insightSlower — need ~2 months of data minimumFaster — a few weeks is enough to see a trend

The query structure is identical for both — only the DATE_TRUNC grain and the DATEDIFF unit change. It's common to build both and use monthly for exec reporting, weekly for product-team debugging of a specific onboarding change.

Common mistakes

  • Counting events, not distinct users. Forgetting DISTINCT in COUNT(DISTINCT user_id) means a single power user's activity inflates the whole cohort's retention rate.
  • Using raw timestamps as the cohort key. Without DATE_TRUNC, every user becomes their own cohort of one, and every retention rate is meaningless (0% or 100%).
  • Including events before the cohort date. Backdated test data or timezone mismatches between signup_at and event_at can produce negative period numbers that silently skew the earliest column.
  • Comparing an incomplete cohort's later periods. A cohort that signed up last month has no "period 3" data yet — showing it as 0% retention is misleading; it should be blank or excluded until enough time has passed.
  • Mixing cohort granularity mid-report. Truncating some cohorts by week and others by month in the same table produces a grid that can't be compared row to row.

Key takeaways

  • Cohort analysis needs two dates: a cohort-defining event (signup) and an activity stream.
  • DATE_TRUNC collapses signup timestamps into shared cohort buckets — raw timestamps never group.
  • The period offset (DATEDIFF between cohort date and activity date) is what turns dates into "months since joining."
  • Retention percentage = active users in a period ÷ the cohort's period-0 total — via a join or FIRST_VALUE() OVER (...).
  • The visual triangle is just a pivot of the row-per-cohort-per-period result using conditional aggregation.