EVIKA ACADEMY · DATA ANALYTICS SERIES · CHAPTER 93

Cohort Analysis for Data Analysts

Acquisition cohorts, retention curves, cohort heatmaps in SQL and Python, rolling vs classic retention, behavioural cohorts, revenue cohorts, LTV calculation, and how to read and present cohort findings.

⏱ 22 min read📈 Retention · LTV · Python · SQL
← Ch 92: Regex for Data AnalystsCh 94: Funnel Analysis →

Aggregate metrics hide the truth. "Monthly active users grew 15%" sounds healthy — but if that growth is entirely from new users and existing users are churning at 40%, the business is leaking value as fast as it adds it. Cohort analysis reveals this. It groups users by when they joined and tracks each group separately, so you can see whether retention is improving or degrading across time, and which acquisition periods produced the most loyal users.

Cohort analysis is one of the most important tools in a product analyst's toolkit. This chapter covers the complete workflow: building cohort tables in SQL, visualising them as heatmaps in Python, understanding the different retention definitions, extending the analysis to revenue and LTV, and reading the patterns that matter.

Types of Cohorts

Acquisition cohort
Users grouped by when they first signed up or made their first purchase. The most common type.
Do January sign-ups retain better than March sign-ups?
Behavioural cohort
Users grouped by an action they took — completed onboarding, used a specific feature, purchased a specific product.
Do users who completed onboarding in week 1 have better 90-day retention?
Revenue cohort
Users grouped by acquisition period, tracked for revenue generated over time. Foundation for LTV calculation.
How much revenue do users acquired in Q1 generate by month 6 vs 12?
Size cohort
Users grouped by a property at acquisition — company size, order value, geography — to compare cohort behaviour across segments.
Do enterprise sign-ups churn at a lower rate than SMB sign-ups?

Building a Cohort Retention Table in SQL

The SQL pattern for cohort analysis always follows the same structure: identify each user's cohort (their first activity date), then for each subsequent period calculate how many users from that cohort were still active.

-- Step 1: Assign each user their cohort month
WITH user_cohorts AS (
    SELECT
        user_id,
        DATE_TRUNC('month', MIN(activity_date)) AS cohort_month
    FROM   user_activity
    GROUP  BY user_id
),

-- Step 2: For each user-activity combination, calculate period number
-- (months since cohort_month)
user_activities AS (
    SELECT
        a.user_id,
        c.cohort_month,
        DATE_TRUNC('month', a.activity_date)               AS activity_month,
        DATEDIFF('month', c.cohort_month,
                 DATE_TRUNC('month', a.activity_date))      AS period_number
    FROM   user_activity      a
    JOIN   user_cohorts       c  USING (user_id)
),

-- Step 3: Count distinct active users per cohort per period
cohort_sizes AS (
    SELECT cohort_month, COUNT(DISTINCT user_id) AS cohort_size
    FROM   user_cohorts
    GROUP  BY cohort_month
)

-- Step 4: Pivot into retention percentages
SELECT
    ua.cohort_month,
    cs.cohort_size,
    ua.period_number,
    COUNT(DISTINCT ua.user_id)                                   AS retained_users,
    ROUND(COUNT(DISTINCT ua.user_id) * 100.0 / cs.cohort_size, 1) AS retention_pct
FROM   user_activities  ua
JOIN   cohort_sizes     cs  USING (cohort_month)
GROUP  BY ua.cohort_month, cs.cohort_size, ua.period_number
ORDER  BY ua.cohort_month, ua.period_number;

-- Result shape:
-- cohort_month | cohort_size | period_number | retained_users | retention_pct
-- 2026-01-01   |    1240     |      0        |     1240       |   100.0
-- 2026-01-01   |    1240     |      1        |      744       |    60.0
-- 2026-01-01   |    1240     |      2        |      558       |    45.0
-- 2026-02-01   |    1380     |      0        |     1380       |   100.0
-- 2026-02-01   |    1380     |      1        |      855       |    61.9

BigQuery variant (DATEDIFF syntax differs)

-- BigQuery uses DATE_DIFF(date1, date2, MONTH) instead of DATEDIFF
DATE_DIFF(DATE_TRUNC(activity_date, MONTH),
          DATE_TRUNC(cohort_date, MONTH), MONTH) AS period_number

Cohort Heatmap in Python

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors

# Assume df has columns: cohort_month, period_number, retention_pct
# (output from the SQL above, loaded into a DataFrame)

# Pivot to cohort heatmap matrix
cohort_pivot = df.pivot_table(
    index='cohort_month',
    columns='period_number',
    values='retention_pct'
)

# Format index as month strings
cohort_pivot.index = cohort_pivot.index.strftime('%b %Y')

# Plot
fig, ax = plt.subplots(figsize=(14, 7))

cmap = mcolors.LinearSegmentedColormap.from_list(
    'retention', ['#fff', '#0d9488'], N=256
)

im = ax.imshow(cohort_pivot.values, cmap=cmap, aspect='auto',
               vmin=0, vmax=100)

# Annotate cells with percentage values
for i in range(cohort_pivot.shape[0]):
    for j in range(cohort_pivot.shape[1]):
        val = cohort_pivot.values[i, j]
        if not np.isnan(val):
            ax.text(j, i, f'{val:.0f}%',
                    ha='center', va='center',
                    color='white' if val > 50 else '#1e293b',
                    fontsize=8, fontweight='bold')

ax.set_xticks(range(cohort_pivot.shape[1]))
ax.set_xticklabels([f'M{i}' for i in cohort_pivot.columns], fontsize=9)
ax.set_yticks(range(cohort_pivot.shape[0]))
ax.set_yticklabels(cohort_pivot.index, fontsize=9)

ax.set_title('Monthly Retention by Acquisition Cohort', fontsize=14, pad=15)
ax.set_xlabel('Months since acquisition')
ax.set_ylabel('Acquisition cohort')

plt.colorbar(im, ax=ax, label='Retention %', shrink=0.8)
plt.tight_layout()
plt.savefig('cohort_heatmap.png', dpi=150, bbox_inches='tight')
plt.show()

Classic vs Rolling Retention

Classic (N-day) Retention
User was active on exactly day N (or month N).
Day 7 classic = active on day 7 ± 1 day window.
Use for: strict engagement tracking, daily-active product loops.
Rolling (Bounded) Retention
User was active on day N or any later day in the observation window.
Day 7 rolling = active on day 7, 8, 9, 10... (before day 14).
Use for: weekly/monthly products, subscription services.
-- Classic Day-7 retention: active on exactly day 7 (±1 day tolerance)
SELECT
    cohort_date,
    COUNT(DISTINCT user_id)                               AS cohort_size,
    COUNT(DISTINCT CASE
        WHEN activity_date BETWEEN cohort_date + INTERVAL '6 days'
                               AND cohort_date + INTERVAL '8 days'
        THEN user_id END)                                 AS day7_retained,
    ROUND(COUNT(DISTINCT CASE
        WHEN activity_date BETWEEN cohort_date + INTERVAL '6 days'
                               AND cohort_date + INTERVAL '8 days'
        THEN user_id END) * 100.0 / COUNT(DISTINCT user_id), 1) AS day7_retention_pct
FROM cohort_base
GROUP BY cohort_date;


-- Rolling (bounded) Day-7 retention: active at any point in days 7–13
SELECT
    cohort_date,
    COUNT(DISTINCT user_id)                               AS cohort_size,
    COUNT(DISTINCT CASE
        WHEN activity_date BETWEEN cohort_date + INTERVAL '7 days'
                               AND cohort_date + INTERVAL '13 days'
        THEN user_id END)                                 AS day7_rolling_retained
FROM cohort_base
GROUP BY cohort_date;

Revenue Cohorts and LTV

-- Revenue cohort: total revenue generated by each acquisition cohort over time
WITH revenue_by_cohort AS (
    SELECT
        c.cohort_month,
        DATE_TRUNC('month', o.order_date)       AS revenue_month,
        DATE_DIFF(
            DATE_TRUNC('month', o.order_date),
            c.cohort_month, MONTH
        )                                        AS months_since_acquisition,
        SUM(o.order_total)                       AS revenue
    FROM   orders          o
    JOIN   user_cohorts    c  USING (user_id)
    GROUP  BY 1, 2, 3
),

cohort_sizes AS (
    SELECT cohort_month, COUNT(DISTINCT user_id) AS cohort_size
    FROM   user_cohorts GROUP BY 1
)

-- Cumulative LTV per user: running total of revenue / cohort_size
SELECT
    r.cohort_month,
    r.months_since_acquisition,
    cs.cohort_size,
    r.revenue,
    SUM(r.revenue) OVER (
        PARTITION BY r.cohort_month
        ORDER BY r.months_since_acquisition
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    )                                            AS cumulative_revenue,
    ROUND(
        SUM(r.revenue) OVER (
            PARTITION BY r.cohort_month
            ORDER BY r.months_since_acquisition
            ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
        ) / cs.cohort_size, 2
    )                                            AS cumulative_ltv_per_user
FROM   revenue_by_cohort r
JOIN   cohort_sizes      cs  USING (cohort_month)
ORDER  BY r.cohort_month, r.months_since_acquisition;

Reading Cohort Heatmaps: Patterns That Matter

Improving retention across cohorts (diagonal brightens going down)
Recent cohorts retain better than older ones. A product improvement is working. The bottom rows of the heatmap are brighter than the top rows at the same period number.
Sharp drop-off at a specific period across all cohorts
Something happens at that period — a paywall, a subscription renewal, an email cadence ending — that causes widespread churn. A horizontal dark band across the heatmap at period N is a product problem worth investigating.
Specific cohort significantly worse than neighbours
Something happened during that acquisition period — a bad ad campaign, a broken onboarding flow, a seasonal audience — that produced lower-quality users. Look for what was different about that month.
Retention stabilising after month 3–4
This is the "power users" floor — the users who will stay regardless. The percentage at which retention flattens tells you your loyal core. If it's above 20%, you have strong product-market fit in that segment.
← Ch 92: Regex for Data AnalystsCh 94: Funnel Analysis →
Learn Product Analytics at EVIKA ACADEMY
Sector 51, Noida · Live classes online & offline
Book Free DemoWhatsApp Us
🎓 Free Demo Class — Online & Offline · Noida Sector 51