Active users and stickiness

How many people really use the app, and how often do they come back?

Lesson 35 min readFree, runs offline
On this page
  1. The problem
  2. What you will build from scratch
  3. The idea
  4. Walk through
  5. Your turn
  6. Check yourself
  7. In real job postings
  8. Next

Get the lesson files

The dataset, exercise.py, the solution and the test live in the open-source repo. You need only Python 3.9 or later.

git clone https://github.com/Ani19arya/analytics-from-scratch
cd analytics-from-scratch/phases/03-metrics-and-product-analytics/03-active-users-and-stickiness
python3 test_lesson.py
Open this lesson on GitHub

The problem

Fitloop, a fictional workout tracking app, is writing its September update for investors. Jo, the founder, wants to report monthly active users and asks you for the number by Wednesday. The growth team counts anyone who opened the app, and Fitloop sends a daily push notification, so a tap on a reminder counts as active even if the user does nothing else. You have the raw event log for 1 to 30 September 2026. You will compute DAU, WAU, MAU and DAU/MAU from the events, under two definitions of active, and show Jo how much the definition changes the answer.

The file data/events.csv is synthetic, generated by make_data.py with a fixed seed. It has 1,990 rows for 170 made-up users: at most one app_open and one log_workout row per user per day.

What you will build from scratch

  • active_by_day(path, actions): the set of active users on each day, for a chosen definition of active.
  • window(end_date, days): the dates in a trailing window.
  • dau, active_in_window: DAU, WAU (7 days) and MAU (30 days).
  • stickiness: DAU/MAU, the share of the month an average monthly user shows up.

The idea

An active user is a user who did a qualifying action in a time window. Both halves are choices you have to write down:

  1. The action. "Any event" is the loosest. A better definition is the action that delivers the product's value; for a workout tracker, that is logging a workout. App opens from a notification are easy to inflate and say little about value.

  2. The window. For a window of d days ending on date T:

     active(T, d) = number of DISTINCT users with a qualifying action on any day in [T - d + 1, T]
     DAU = active(T, 1)    WAU = active(T, 7)    MAU = active(T, 30)
    

Distinct is the whole point. A user active on 20 days is one monthly user. That is why MAU is not 30 times DAU, and why you cannot add up daily counts to get a monthly one.

Stickiness compares the two:

DAU/MAU = (average DAU over the 30 days) / MAU

If every monthly user came every day, DAU/MAU would be 1. If each came on exactly one day, it would be 1/30, about 0.033. Read it as the fraction of days in the month the average monthly user is active: 0.25 means about 7.5 days out of 30. Use the average DAU across the window, not a single day, or one quiet Sunday decides your stickiness.

Whether a value is good depends on the product. A daily habit app and a tax filing app should not be judged on the same scale, so compare a product against itself over time and keep the definition fixed while you do.

Walk through

Step 1. The set of users per day, under a definition.

import csv
def active_by_day(path, actions=None):
    days = {}
    with open(path, newline="") as f:
        for row in csv.DictReader(f):
            if actions is None or row["event_name"] in actions:
                days.setdefault(row["event_date"], set()).add(int(row["user_id"]))
    return days

loose = active_by_day("data/events.csv")
strict = active_by_day("data/events.csv", {"log_workout"})
print(len(loose["2026-09-30"]), len(strict["2026-09-30"]))   # 41 18

Step 2. A trailing window is a union of daily sets, not a sum of daily counts.

import datetime
def window(end_date, days):
    end = datetime.date.fromisoformat(end_date)
    return [(end - datetime.timedelta(days=i)).isoformat() for i in range(days - 1, -1, -1)]

week = window("2026-09-30", 7)
print(week[0], week[-1])                                   # 2026-09-24 2026-09-30
by_day = {"2026-09-29": {1, 2}, "2026-09-30": {1, 3}}
print(sum(len(by_day[d]) for d in by_day))                 # 4  (wrong: user 1 counted twice)
print(len(by_day["2026-09-29"] | by_day["2026-09-30"]))    # 3  (right)

Step 3. Run python3 solution/solution.py for the full comparison, with 30 September as the end date:

definition DAU on 30 Sep average DAU WAU MAU DAU/MAU
any event 41 48.4 128 170 0.285
logged a workout 18 17.9 52 85 0.211

Same users, same month, and MAU is 170 or 85 depending on one sentence in the definition. Every one of the 170 users opened the app at least once, but only 85 logged a workout. Average DAU drops by almost two thirds, because most daily opens are notification taps from people who do not work out.

What to tell Jo: "Report 85 monthly active users, defined as people who logged at least one workout in September. 170 people opened the app, but half of them never logged anything, so the bigger number would not survive a question from an investor. The users who do work out log on about 6 days a month (DAU/MAU 0.21). Whichever definition we pick, we keep it fixed so next month is comparable."

Your turn

Open exercise.py and fill in the five functions using only csv and datetime. Then run, from this folder:

python3 test_lesson.py

To see the reference solution pass: CHECK_SOLUTION=1 python3 test_lesson.py.

Check yourself

  1. DAU is 50 every day of a 30-day month. Is MAU 1,500?
  2. Why is "opened the app" a weak definition of active?
  3. DAU/MAU went from 0.20 to 0.25 this month. Name two very different reasons that could happen.
  4. Why compute DAU/MAU with the average DAU over the window instead of DAU on the last day?
Answers
  1. No. MAU counts distinct users, so it is somewhere between 50 (the same 50 people every day) and 1,500 (different people every day). You need the user ids, not the daily totals.
  2. It does not require the user to get any value, and it is easy for the company itself to move: more push notifications raise it without changing how useful the product is. Pick the action that shows the product did its job.
  3. Engaged users came back more often (good), or occasional users stopped coming at all so MAU shrank while the core stayed (not good). Always look at DAU and MAU alongside the ratio.
  4. One day is noisy and depends on the weekday. The average across the same window as MAU gives a stable number that compares like with like.

In real job postings

In the Real Analyst Jobs live feed on 25 October 2026, 48% of the 2,102 US analyst postings that list tools ask for Python, often for product work like turning raw events into active user metrics. See Python for analyst jobs and browse open roles at Real Analyst Jobs.

Next

04: North star and guardrails

Put the skill to work

Real Analyst Jobs lists analyst roles read daily from company career pages, with the skills each posting asks for.

Browse analyst jobs

All lessons in Analytics from Scratch

Raj