The "what is an active user" problem
A Tuesday standup at Swiggy. Riya from growth shows a dashboard: 4.6 crore weekly active users, up 3% week-over-week. Karan from finance opens his deck for the same week: 4.2 crore. Aditi, the central analytics lead, runs her own SQL on the spot: 3.9 crore. Same warehouse, same fct_user_events table, same time window, three answers separated by 18%. Nobody has bugged code. Each query is internally correct. The disagreement is older than any of the three queries — it is about what the word "active" means, and the meeting is about to spend forty-five minutes discovering that nobody had ever written it down.
"Active user" is the canonical metrics-layer puzzle because it has at least a dozen valid SQL definitions, each producing a different number, and every definition is internally consistent. The metrics layer's job is not to discover the "right" definition — it is to force the org to commit to one, version it, and make every dashboard cite which version it used. Every team that skipped this step rediscovers it the hard way.
Why one phrase produces twelve numbers
"Active user" hides at least four orthogonal choices, and each choice has multiple legitimate values:
- What counts as an "action"? Opening the app, logging in, completing a transaction, scrolling for ≥30 seconds, sending a message — each event type lives in its own table or has its own
event_typefilter, and product teams across an org will pick different ones. - What identifier counts as a "user"?
user_id(logged in),device_id(anyone with the app installed),cookie_id(web visitors),phone_hash(matched across logged-in and logged-out states), or all of the above with a graph-based identity resolver. - What time window counts as the "period"? Trailing 7 days from the query moment, calendar week (Mon–Sun), ISO week, fiscal week, week-ending-Sunday — the bucketing function alone produces 4–5 numbers.
- What user attributes apply — as-of when? A user in Pune who moved to Bengaluru in March: do you count them in "active users in Maharashtra for week of 2026-04-01" using their March address or their April address? Both answers are legal; only one matches what the marketing slide deck assumes.
Multiply: 3 plausible action types × 2 identity choices × 2 time-bucketing conventions × 2 as-of choices = 24 possible numbers. In practice teams converge on around 12 because some combinations don't make sense, but the explosion is real and visible at every company that crosses 50 dashboards.
A side-by-side worked example
Take a single week of Swiggy data (synthetic but realistic shape) and compute four definitions on the same rows. The numbers diverge by 22% across the four — and these are not bugs.
# wau_definitions.py — same data, four legitimate "WAU" answers.
import duckdb
con = duckdb.connect()
con.execute("""
CREATE TABLE fct_user_events AS
SELECT * FROM (VALUES
-- (user_id, device_id, event_ts, event_type, country)
(101, 'd1', TIMESTAMP '2026-04-13 09:12:00', 'session_start', 'IN'),
(101, 'd1', TIMESTAMP '2026-04-15 18:30:00', 'order_placed', 'IN'),
(102, 'd2', TIMESTAMP '2026-04-13 11:00:00', 'session_start', 'IN'),
(102, 'd2', TIMESTAMP '2026-04-13 11:01:00', 'scroll_30s', 'IN'),
(103, 'd3', TIMESTAMP '2026-04-14 14:20:00', 'session_start', 'IN'),
(103, 'd9', TIMESTAMP '2026-04-16 09:00:00', 'session_start', 'IN'), -- new device
(NULL,'d4', TIMESTAMP '2026-04-15 22:00:00', 'session_start', 'IN'), -- logged-out
(NULL,'d5', TIMESTAMP '2026-04-15 22:30:00', 'scroll_30s', 'IN'),
(104, 'd6', TIMESTAMP '2026-04-12 23:59:00', 'session_start', 'IN'), -- prior week
(105, 'd7', TIMESTAMP '2026-04-19 23:30:00', 'session_start', 'IN') -- next week boundary
) AS t(user_id, device_id, event_ts, event_type, country);
""")
WEEK = "event_ts >= '2026-04-13' AND event_ts < '2026-04-20'"
defs = {
"D1: distinct user_id, session_start only":
f"SELECT COUNT(DISTINCT user_id) FROM fct_user_events "
f"WHERE {WEEK} AND event_type='session_start' AND user_id IS NOT NULL",
"D2: distinct device_id, session_start only":
f"SELECT COUNT(DISTINCT device_id) FROM fct_user_events "
f"WHERE {WEEK} AND event_type='session_start'",
"D3: distinct user_id, ANY event_type":
f"SELECT COUNT(DISTINCT user_id) FROM fct_user_events "
f"WHERE {WEEK} AND user_id IS NOT NULL",
"D4: distinct device_id, ANY event_type (incl. logged-out)":
f"SELECT COUNT(DISTINCT device_id) FROM fct_user_events WHERE {WEEK}",
}
for label, q in defs.items():
print(f"{label:55s} -> {con.execute(q).fetchone()[0]}")
# Sample run:
D1: distinct user_id, session_start only -> 4
D2: distinct device_id, session_start only -> 6
D3: distinct user_id, ANY event_type -> 4
D4: distinct device_id, ANY event_type (incl. logged-out) -> 7
Walk the load-bearing pieces:
WEEK = "event_ts >= '2026-04-13' AND event_ts < '2026-04-20'"— half-open interval, ISO-week starting Monday. The user withevent_ts = '2026-04-12 23:59:00'is excluded; the user at'2026-04-19 23:30:00'is included; flip either bound by one second and the count changes. Why half-open: closed-on-both-sides intervals double-count the boundary. The convention[start, end)is the only one that lets you concatenate weeks without overlap.event_type='session_start'filter (D1) — restricts "active" to "opened the app". This is the marketing team's definition because it counts the broadest funnel-top.COUNT(DISTINCT device_id)(D2) — counts logged-out users too. User 103 used two devices the same week and is counted twice (d3,d9). The growth team likes this because it credits cross-device usage; the finance team hates it because it inflates the number.ANY event_type(D3) — drops the action filter. A user who only scrolled but never started a session still counts. Why this widens the funnel: scroll events fire on push-notification deep-links; counting those users matches "users who saw an in-app surface" rather than "users who opened the app".event_type='session_start' AND user_id IS NOT NULL— theIS NOT NULLclause silently excludes logged-out activity. The'd4'and'd5'rows (logged-out web visitors) never make it into D1 or D3. Why this matters: logged-out activity is 15-25% of total traffic at most Indian B2C apps. Choosing user_id-based DAU silently drops a quarter of your reality.
D1 and D3 happen to match here at 4, but only because of the specific synthetic data. On real Swiggy-scale data they typically differ by 6-12% because some users perform actions (like accepting a push-notification deep-link) without ever firing a session_start.
The committee meeting that actually fixes it
The fix is not "find the right definition". The fix is to run a 90-minute meeting that produces three artefacts and then never re-run it. The artefacts:
-
A primary definition, named and versioned. "WAU v1 = distinct user_id with at least one
session_startevent in the trailing ISO-week, attributes resolved as-of-today." That is the layer's default. Every dashboard that says "WAU" cites v1 unless explicitly noting otherwise. -
A documented set of variants, each with a use case. WAU v1 (default),
wau_device(device-id based, for growth),wau_engaged(any event, for product),wau_paying(transactional events only, for finance). Four metrics, four named definitions, four explicit purposes. Anything outside this set requires a registry update. -
A migration plan for existing dashboards. A spreadsheet with every dashboard, the SQL it currently runs, the variant it should map to, the expected delta in the headline number, and the date it will switch. Without this, the 90-minute meeting was theatre — every "single source of truth" rollout dies because the rollout-tracking artefact was never produced.
The hardest part is not the technical encoding. The hardest part is having the political mandate to say "from 2026-05-01, the board deck cites WAU v1; if you want a different number, register wau_v2 and explain why." Razorpay's central data team got this mandate after a 2024 board meeting where the CFO and CMO presented WAU numbers off by 11% on the same slide; Cred's data team got it after a 2025 fundraise diligence call that asked which number to trust. The mandate is usually downstream of an embarrassment.
What the metrics layer actually has to encode
Once the committee has named the four metrics, the metrics-layer registry must encode:
- The expression (
COUNT(DISTINCT user_id)vsCOUNT(DISTINCT device_id)). - The table (
fct_user_events— and which version of it; if the table schema evolves, the metric must pin to a schema version). - The base filter (
event_type = 'session_start'vsevent_type IN (...transactional list...)). - The time grain semantics — half-open ISO week vs trailing-7d, with explicit start/end columns.
- The as-of policy for dimensions — whether
countryjoins todim_userusingcurrent = TRUE(today's address) or using a temporal join (event_ts BETWEEN dim_user.valid_from AND dim_user.valid_to). - The null-handling policy — what happens when
user_id IS NULL. Counted? Excluded? Bucketed under a sentinel'logged_out'? - The version —
v1,v2, with a deprecation date for older versions.
Six fields plus a version. That is the whole encoding. Every metrics-layer tool — Looker, MetricFlow, Cube, dbt — implements these fields with different syntax. The semantic content is identical.
Common confusions
- "Just pick one definition and enforce it." The four variants exist because four different decisions are being made by people. Forcing one definition means three teams produce numbers that mislead their decisions. The right move is to register all four with names, not to pretend three of them don't exist.
- "WAU is just COUNT DISTINCT user_id." That is a guess at one definition out of twelve. Without specifying action type, time-bucketing, identity column, and as-of policy, "COUNT DISTINCT user_id" is incomplete in the same way "the angle is 30" is incomplete without saying degrees-or-radians.
- "As-of-today is fine; nobody changes country." In Indian B2C apps, 2-4% of users change country each year (Bengaluru → US H-1B, OCI returnees, work-permit moves). At 5 crore MAU that is 10-20 lakh users whose attribution silently changes. The error compounds when you compare year-over-year by region.
- "Logged-out users don't matter." They are 15-25% of traffic at most Indian e-commerce apps and 30%+ at content apps (news, video). Excluding them with a silent
IS NOT NULLfilter is a 15-30% understatement of reach. It is fine to choose to exclude them — but the exclusion has to be explicit and documented, not accidental. - "The committee meeting is bureaucratic overhead." The committee is the metrics-layer's most leveraged hour of the year. Every team that skipped it spent the next year debugging dashboard disagreements and lost trust in the data org. The committee is cheap; the alternative is expensive.
Going deeper
How LinkedIn's data team versioned their flagship metric
LinkedIn's "weekly visiting members" metric — their public-disclosed engagement number — went through three versions between 2018 and 2023 as the company refined what counted. Each version change was announced internally with a six-month transition where both old and new numbers were reported in parallel, and external disclosures specified which version was being cited. The lesson for Indian-scale companies (Flipkart, Swiggy, Zomato preparing for IPO disclosures): the metric in your S-1 filing is a contract with regulators and investors; "we changed how we counted" is not a defence three years later. Version, parallel-report, and pin.
As-of-period vs as-of-today: the SCD-2 join trap
The "as-of-period" semantic — joining a user's attribute table using the period's timestamp rather than today — requires a Slowly Changing Dimension Type 2 (SCD-2) table on the user side. Most teams ship dim_user as SCD-1 (just current values), which makes as-of-period queries impossible without a full historical rebuild. Adding SCD-2 retroactively requires either a CDC log on the user table from day one (rare) or a "reasonable approximation" rebuild from event history (lossy). The cheapest fix is to mandate SCD-2 on every dim_table from day one, even though 80% of analyses use as-of-today; the 20% that need as-of-period will not negotiate.
Backfilling a v1 metric when the source schema changed
Razorpay's payments team published WAU back to 2021 when they registered the v1 metric in 2024. The challenge: the fct_user_events table's schema had changed three times — event_type enum values were added ('session_start' was renamed from 'app_open' in 2022), and the user_id column moved from INT to BIGINT in 2023. The backfill SQL had to apply different logic per time period: pre-2022 used the old enum, 2022-2023 used a coalesce, 2023-onward used the current schema. The backfill script lives in version control, the parallel-period numbers were validated against three preserved historical dashboards, and a runbook documents the schema-shift dates. This is the kind of work no metrics-layer tool will do for you; it is engineering labour amortised over the next decade of board meetings.
When the committee can't agree (and what Cred did)
In a 2024 Cred standup, the growth team and finance team could not agree on whether "active" should require a transactional event. Growth wanted session_start (broader funnel); finance wanted transactional event (narrower, revenue-aligned). The compromise: register both as separate top-level metrics — wau (session-based, the company-default) and wau_paying (transactional, the finance default) — and require every dashboard to label which one it shows. This sounds obvious, but the political work was getting both teams to stop calling their own number "WAU" and start labelling it. The artefact that locked the change in was a slack-bot that posted a daily reminder to the analytics channel showing the two numbers side by side. After three months the labelling habit stuck.
Where this leads next
The metrics layer's encoding lives in tools like dbt + MetricFlow (/wiki/dbt-as-the-de-facto-transformation-layer) and headless engines (/wiki/the-headless-bi-movement). The cross-tool comparison (/wiki/lookml-cube-metricflow-the-landscape) walks through how each tool encodes the seven fields above, and where they fall short.
The deeper question — does the metrics layer require human governance forever, or can LLMs constrain themselves to the registry? — is in /wiki/semantic-layer-llms-the-new-interface. And the meta-narrative of why this category keeps getting reinvented is the previous chapter, /wiki/wall-the-metrics-layer-keeps-getting-re-invented.
Build 13 as a whole answers the question the warehouse cannot: when bytes have left the engine and meaning starts, who owns the meaning. The "active user" problem is the canonical case, but the same shape recurs for "revenue", "conversion rate", "retention" — every aggregate that humans argue about. The mechanism is identical; learn it once, apply it ten times.
References
- Mark Rittman: WAU is a Verb, not a Number — a working consultant's framing of why "active user" must be paired with a verb (opened, transacted, scrolled) to be measurable.
- LinkedIn Engineering: Versioning Engagement Metrics — internal write-up of how LinkedIn versioned their public-facing "weekly visiting members" number.
- dbt MetricFlow — Metric Definitions Spec — current canonical syntax for encoding the seven fields above.
- Cube.dev — Defining Cubes and Measures — Cube's encoding of the same seven fields, with as-of semantics built into the dimension layer.
- Kimball: Slowly Changing Dimensions, the Type 2 Variant — the foundational paper on SCD-2 that every "as-of-period" join depends on.
- Benn Stancil: Counting Users is the Hardest Easy Problem — the 2022 essay that introduced the "twelve definitions" framing to the modern data community.
- /wiki/wall-the-metrics-layer-keeps-getting-re-invented — the previous chapter; why the metrics layer category exists at all.
- /wiki/dbt-as-the-de-facto-transformation-layer — the next chapter; the tool every metrics layer in 2026 sits on top of.