User Retention Curve by Cohort — Pygal

A line chart showing the percentage of retained users over time since signup, with separate curves for different cohorts. All curves start at 100% at time zero and typically exhibit exponential decay, revealing how well a product retains users over their lifecycle. By overlaying multiple cohorts, teams can visually compare whether retention is improving or degrading across signup periods.

User Retention Curve by Cohort rendered with Pygal

Python source (Pygal)

""" anyplot.ai
line-retention-cohort: User Retention Curve by Cohort
Library: pygal 3.1.3 | Python 3.13.14
Quality: 86/100 | Updated: 2026-06-20
"""

import os
import sys


# Script filename shadows the installed `pygal` package when run as `python pygal.py`;
# dropping the script directory from sys.path lets the real package resolve.
sys.path.pop(0)

import numpy as np
import pygal
from pygal.style import Style


# Theme tokens — Imprint palette chrome
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Imprint categorical palette — canonical order, position 1 = brand green
IMPRINT_PALETTE = ("#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314")
# 5 cohort series use positions 1-5; threshold reference line gets muted neutral
SERIES_COLORS = IMPRINT_PALETTE[:5] + (INK_MUTED,)

# Data — Monthly signup cohorts tracked weekly for 12 weeks
np.random.seed(42)

cohorts = {
    "Jan 2025": {"size": 1245, "decay": 0.18},
    "Feb 2025": {"size": 1102, "decay": 0.16},
    "Mar 2025": {"size": 1380, "decay": 0.14},
    "Apr 2025": {"size": 1467, "decay": 0.12},
    "May 2025": {"size": 1590, "decay": 0.11},
}

weeks = list(range(13))
retention_data = {}
for cohort, params in cohorts.items():
    retention = [100.0]
    for week in range(1, 13):
        noise = np.random.normal(0, 1.5)
        prev = retention[-1]
        drop = prev * params["decay"] * (1 / (1 + 0.1 * week)) + noise
        retention.append(max(round(prev - max(drop, 0.5), 1), 5.0))
    retention_data[cohort] = retention

# Title — 51 chars, within 67-char baseline so no font scaling needed
title = "line-retention-cohort · python · pygal · anyplot.ai"

# Style — theme-adaptive Imprint palette tokens
custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=SERIES_COLORS,
    title_font_size=66,
    label_font_size=56,
    major_label_font_size=44,
    legend_font_size=44,
    value_font_size=36,
    opacity=".95",
    opacity_hover="1",
    stroke_width=3,
    font_family="'Segoe UI', 'Helvetica Neue', Arial, sans-serif",
    title_font_family="'Segoe UI', 'Helvetica Neue', Arial, sans-serif",
    legend_font_family="'Segoe UI', 'Helvetica Neue', Arial, sans-serif",
    label_font_family="'Segoe UI', 'Helvetica Neue', Arial, sans-serif",
    major_label_font_family="'Segoe UI', 'Helvetica Neue', Arial, sans-serif",
    value_font_family="'Segoe UI', 'Helvetica Neue', Arial, sans-serif",
)

# Plot
chart = pygal.Line(
    width=3200,
    height=1800,
    title=title,
    x_title="Weeks Since Signup",
    y_title="Retained Users (%)",
    style=custom_style,
    show_dots=True,
    dots_size=6,
    stroke_style={"width": 4},
    show_y_guides=True,
    show_x_guides=False,
    legend_at_bottom=True,
    legend_at_bottom_columns=3,
    legend_box_size=28,
    truncate_legend=-1,
    range=(0, 102),
    x_label_rotation=0,
    value_formatter=lambda x: f"{x:.0f}%" if x is not None else "",
    tooltip_fancy_mode=True,
    tooltip_border_radius=8,
    interpolate="cubic",
    show_minor_x_labels=False,
    y_labels=[0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100],
    margin_top=50,
    margin_bottom=50,
    margin_left=30,
    margin_right=30,
    spacing=25,
    print_values=False,
    dynamic_print_values=True,
    no_data_text="No data available",
)

chart.x_labels = [str(w) for w in weeks]
chart.x_labels_major = ["0", "3", "6", "9", "12"]

# Add cohorts: oldest=thinnest/smaller dots, newest=thickest/larger for visual emphasis
stroke_widths = [4, 5, 6, 7, 8.5]
dot_sizes = [5, 6, 7, 8, 10]
cohort_list = list(cohorts.items())

for i, (cohort, params) in enumerate(cohort_list):
    label = f"{cohort} (n={params['size']:,})"
    values = retention_data[cohort]
    chart.add(
        label,
        [
            {"value": v, "label": f"Week {w}: {v:.1f}% retained ({int(params['size'] * v / 100):,} users)"}
            for w, v in zip(weeks, values, strict=True)
        ],
        stroke_style={"width": stroke_widths[i], "linecap": "round", "linejoin": "round"},
        dots_size=dot_sizes[i],
        allow_interruptions=False,
    )

# Reference threshold at 20% retention (gets muted neutral — 6th color in SERIES_COLORS)
# Dash-dot pattern "28, 8, 4, 8" is visually distinct from pygal's Y-guide dashes in light mode
chart.add(
    "─ 20% Retention Threshold",
    [{"value": 20.0, "label": "Target: 20% retention benchmark"}] * len(weeks),
    stroke_style={"width": 5.5, "dasharray": "28, 8, 4, 8", "linecap": "round"},
    show_dots=False,
    dots_size=0,
    allow_interruptions=False,
)

# Save PNG and interactive HTML
chart.render_to_png(f"plot-{THEME}.png")
with open(f"plot-{THEME}.html", "wb") as f:
    f.write(chart.render())

Part of User Retention Curve by Cohort on anyplot.ai.

Other implementations