Module 3: Cohort Retention

Reading a cohort table

Overview

The previous lessons looked at cohorts one at a time. In practice, no product team analyzes a single isolated cohort — they build a cohort table: one row per entry week, one column per week since that entry, and in each cell, that cohort's retention percentage at that point in its life. It's, by far, the most-used visualization in any product analytics tool (Amplitude, Mixpanel, and practically any other), and it has a very particular shape: it's triangular. Older cohorts have many filled-in columns (they've been around longer, so there are more weeks to measure); newer cohorts have few or none —they simply haven't existed long enough yet—.

That triangular shape isn't a flaw in the table: it's information. And the full table can be read two different ways, which answer two different questions — reading a row (a single cohort, over time) answers "how does this specific cohort leak?"; reading a column (every cohort, at the same point in life) answers "is the product getting better at retaining people, generation after generation?". This lesson teaches you to read both.

How this connects to the module. This lesson reuses retentionCurve()'s same rate formula (active ÷ initial × 100, lesson 3) to build a full table, and solves exactly the trap lesson 5 left open: how to compare different cohorts without falling into the mistake of mixing different weeks of life.

An analogy: a pet shelter's litter log

An animal shelter keeps a log by litter: each group of puppies born the same week forms its own row, and the shelter records, week after week since birth, how many of that specific litter are still up for active adoption (not yet adopted, not lost track of). The litter born three months ago has twelve weeks of log entries; the litter born yesterday has only one filled-in column —this same week's—, and the rest of its row stays blank, not because the data is missing, but because that data doesn't exist yet: not enough time has passed.

If the shelter wants to know "how did the March litter specifically do?", it reads that row start to finish. But if it wants to know "are we getting better at finding puppies homes faster than before?", it doesn't look at a row — it compares the same week of life across different litters: how many from the January litter were still unadopted at their week 2, versus how many from the March litter were still unadopted at their own week 2. That's exactly the difference between reading by row and reading by column in a product cohort table.

Worked example: Mercado's cohort table

Five Mercado cohorts, one per week. We reuse retentionCurve()'s same rate formula to build the full table, with newer cohorts leaving empty cells where there's no data yet:

// Reusing retentionCurve()'s rate formula (lesson 3): active / initial * 100.
// Five Mercado cohorts, one per week. The newer ones don't yet have enough
// weeks of life to fill out the whole row -- that's why the table comes out
// TRIANGULAR: each newer row has fewer columns than the one before it.
const cohorts = [
  { week: '2026-06-01', active: [1000, 380, 290, 255, 248] },
  { week: '2026-06-08', active: [1100, 430, 320, 285] },
  { week: '2026-06-15', active: [950, 390, 300] },
  { week: '2026-06-22', active: [1200, 510] },
  { week: '2026-06-29', active: [1300] },
];

function cohortTable(cohorts) {
  const maxWeeks = Math.max(...cohorts.map((c) => c.active.length));
  const header = ['Cohort'.padEnd(12)]
    .concat(Array.from({ length: maxWeeks }, (_, w) => ('W' + w).padStart(6)))
    .join(' | ');
  console.log(header);
  console.log('-'.repeat(header.length));
  cohorts.forEach((c) => {
    const rates = c.active.map((a) => ((a / c.active[0]) * 100).toFixed(0) + '%');
    const cells = Array.from({ length: maxWeeks }, (_, w) => (rates[w] ? rates[w] : '--').padStart(6));
    console.log(c.week.padEnd(12) + ' | ' + cells.join(' | '));
  });
}

console.log('=== Mercado\'s cohort table (triangular) ===\n');
cohortTable(cohorts);

console.log('\n=== Reading a ROW (a single cohort over time) ===');
console.log('Cohort 2026-06-01: 100% -> 38% -> 29% -> 25.5% -> 24.8%  (its own leak, week by week)');

console.log('\n=== Reading a COLUMN (every cohort at the SAME point in life) ===');
const w1 = cohorts.filter((c) => c.active.length > 1).map((c) => ({
  week: c.week,
  rate: ((c.active[1] / c.active[0]) * 100).toFixed(1) + '%',
}));
w1.forEach((c) => console.log('  ' + c.week + ' at its week 1: ' + c.rate));
console.log('\nColumn W1 rises from 38.0% to 42.5% cohort after cohort: each newer');
console.log('generation retains better in its first week than the previous one -- a real');
console.log('improvement signal that NEVER shows up comparing rows of different lengths.');

What to expect. When you run the file with Node, the output is exactly this:

=== Mercado's cohort table (triangular) ===

Cohort       |     W0 |     W1 |     W2 |     W3 |     W4
---------------------------------------------------------
2026-06-01   |   100% |    38% |    29% |    26% |    25%
2026-06-08   |   100% |    39% |    29% |    26% |     --
2026-06-15   |   100% |    41% |    32% |     -- |     --
2026-06-22   |   100% |    43% |     -- |     -- |     --
2026-06-29   |   100% |     -- |     -- |     -- |     --

=== Reading a ROW (a single cohort over time) ===
Cohort 2026-06-01: 100% -> 38% -> 29% -> 25.5% -> 24.8%  (its own leak, week by week)

=== Reading a COLUMN (every cohort at the SAME point in life) ===
  2026-06-01 at its week 1: 38.0%
  2026-06-08 at its week 1: 39.1%
  2026-06-15 at its week 1: 41.1%
  2026-06-22 at its week 1: 42.5%

Column W1 rises from 38.0% to 42.5% cohort after cohort: each newer
generation retains better in its first week than the previous one -- a real
improvement signal that NEVER shows up comparing rows of different lengths.

Look at the table's triangular shape: the 2026-06-29 row (the newest cohort, just entering) only has the W0 column filled in — the rest are dashes, not zeros. That distinction matters: a dash means "not enough time has passed to know yet"; a zero would mean "we know nobody from this cohort is still active", which is a completely different claim and, in this case, false. Confusing "no data yet" with "the data is zero" is a common reading mistake, and the reason today's cohortTable() prints -- instead of forcing a 0%.

Now compare the two readings. Reading 2026-06-01's row, you see a single cohort's characteristic leak: 100% → 38% → 29% → 26% → 25%, the same drop-and-plateau shape from lessons 3 and 4. Reading the W1 column top to bottom —38.0%, 39.1%, 41.1%, 42.5%—, you see something no single row could show you: each newer cohort retains better, in its own first week, than the previous cohort did in its own. That progressive column-by-column improvement is exactly the kind of real product-improvement signal lesson 5's aggregate WAU can't tell apart from a simple volume increase.

Why comparing rows of different lengths is a mistake

It's tempting, looking at today's table, to compare 2026-06-01's final 25% (its week 4) against 2026-06-22's 43% (only its week 1) and conclude "the June 22 cohort is retaining much better". That comparison is measuring two different things: a cohort that already went through its entire initial drop and reached its plateau, versus another that hasn't had time to start leaking in earnest yet. The correct way to compare always respects the column: W1 against W1, W4 against W4 — never the last available cell of one row against the last available cell of another, if those cells aren't in the same column.

Common mistakes

Comparing cohorts of different life lengths. What happens: someone compares the latest available data point from row 2026-06-01 (25%, its week 4) against the latest available data point from 2026-06-22 (43%, only its week 1), as if they were comparable. Why it happens: both are "the most recent number we have for this cohort", and comparing "the most recent against the most recent" feels natural, even though they correspond to completely different weeks of life. How to spot it: the comparison doesn't check that both cells are in the same column (W). How to fix it: as in today's example, always compare column against column —the same number of weeks since entry— never "the newest data point of each row".

Reading only rows and never columns. What happens: a team reviews the cohort table every week, always looking at how the most recent cohort is evolving (reading by row), without ever comparing the same column across different cohorts. Why it happens: following a single cohort over time feels like the "natural" way to read a table —left to right—, while reading a column top to bottom requires a less habitual shift of attention. How to spot it: after months of reviewing the table, nobody on the team can say whether week-1 retention is improving or worsening generation after generation —they can only talk about how the most recent cohort did. How to fix it: review the full table in both directions every time, as today's example did — the column is, often, where the signal of whether the product is truly improving lives.

Filling empty cells with 0% or with an average. What happens: someone fills in the triangle with zeros or with the average of existing rows, "so the table looks complete" in a report. Why it happens: a table with holes feels incomplete or unprofessional, and filling in the blanks looks like a visual improvement. How to spot it: the final table has no empty cell at all, even though it includes cohorts only one or two weeks old. How to fix it: leave data-less cells as such —a dash, a blank space, null— never as zero. A zero asserts "we know this cohort hit zero this week", which is false information; a blank space correctly says "we don't know that yet".

Exercises

Exercise 1 — Read today's table. Using the cohort table run above, which cohort had the highest retention at its week 2 (W2)? Give the cohort's name and the percentage.

See solution

Cohort 2026-06-15, with 32% at its W2 — higher than 2026-06-01 (29%) and 2026-06-08 (29%). It's the same column the lesson mentions as an improvement trend: each newer cohort with data at W2 retains a bit better than the previous one at that same point in life.

Exercise 2 — Complete a new row. A sixth cohort, 2026-07-06, has this data: [1050, 470]. Calculate its W0 and W1 rates, and say which columns of the original table (above) it could be directly compared against.

See solution

Rates: W0 = 1050/1050 = 100%; W1 = 470/1050 = 44.8% (rounded, 45%). This row only has data at W0 and W1, so it can only be directly compared (column against column) against those same two columns from the other five cohorts — that is, against W1 of 2026-06-01 (38%), 2026-06-08 (39%), 2026-06-15 (41%), and 2026-06-22 (43%). It can't be compared, for example, against 2026-06-01's W4 (25%), because this new cohort doesn't have that column yet. In fact, at 44.8-45%, this sixth cohort would continue the same progressive W1 improvement trend already visible in the original table.

Exercise 3 — Design the right alert. The Mercado team wants an automatic alert that triggers when a new cohort is retaining worse than the previous cohort at the same point in life (a signal something got worse in the product). Describe, in pseudocode or precise prose, exactly which two table cells you'd need to compare for that cohort and that week.

See solution

For the cohort that entered in week N, and its most recent available data point in column W_k, the alert should compare table[cohort_N][W_k] against table[cohort_N-1][W_k] — the same column (W_k), but the immediately previous cohort's row. If table[cohort_N][W_k] < table[cohort_N-1][W_k], trigger the alert. The explicit condition of "same column, consecutive rows" is the part that guarantees a fair comparison; comparing against any other column, or against an average of several previous cohorts, would reintroduce exactly this lesson's "comparing cohorts of different life lengths" mistake.

Summary and next step

In this lesson you built a full cohort table —triangular by definition, with newer cohorts showing fewer filled-in columns— and learned to read it in both directions: by row (a specific cohort's leak over time) and by column (whether the product is improving, generation after generation, at the same point in life). You saw, with real Mercado data, how the W1 column revealed a progressive improvement —38% to 43%— that no single row could have shown.

Before moving on you should be able to: build a cohort table from several data arrays, explain why the table comes out triangular, and correctly compare two cohorts using the same column instead of each one's last available data point.

Lesson 7 closes the module's conceptual framework: how a retention window like "D7" or "D30" is precisely defined (this table column's formal name), and why, of every metric a product team can measure, retention is the one that best approximates the real value the product delivers.

Resources

  • Mixpanel, "Cohort analysis: How to read the chart, choose a platform, and turn retention into growth" — mixpanel.com/blog/cohort-analysis. Literally instructs: "read across a row to follow a cohort" and "read down a column to compare cohorts at the same point in their life" — the direct source of this lesson's central distinction. In English.
  • Mixpanel, "Retention: Measure engagement over time" (official documentation) — docs.mixpanel.com/docs/reports/retention. The technical reference for how a real product tool builds and displays this same triangular table. In English.
  • Amplitude, "What Is Cohort Retention Analysis: Essential Metrics Guide" — amplitude.com/explore/analytics/cohort-retention-analysis. Complements with visual examples of the triangular table in a real analytics product. In English.