Vineet Ganti
SQL for Data Engineers: Product Analytics Pipelines
All posts

Data Engineering/Data Engineering SQL · Part 4 of 6

·54 min read

SQL for Data Engineers: Product Analytics Pipelines

Part 4 of a data engineering SQL practice bank: a SaaS clickstream turned into sessions, ordered funnels, cohorts, active users, attribution, MRR movement, churn labels and percentiles, kept correct under duplicates and late events. Every answer is run on real sample data.

Data EngineeringSQLPracticeAnalytics

StreamLoop is a SaaS product that emits a clickstream: page views, carts, purchases, logins. This post builds the analytics layer data engineers own for a product like that: turning JSON events into typed rows, sessions, funnels, cohorts, active-user counts, revenue movement and churn, and keeping all of it correct as events arrive late and twice.

The post covers four areas:

  • Events in: flattening JSON and measuring duplication
  • Sessions and funnels
  • Users over time: cohorts, active users, attribution, revenue movement, churn and percentiles
  • Incremental marts: absorbing redeliveries and late events

#How to use this post

Each question has a difficulty, the pattern it drills, a small sample of data and the expected output. Try it before you open the dropdowns: Hint nudges you, and Answer has the SQL, a line-by-line explanation and a step-through of the data. The step-through shows the real tables at each stage, with changed rows tinted and labelled: inserted or kept rows in green, removed rows in red, changed rows in amber. Every answer ends with an Engineer check: the grain, what happens if it runs twice, and what NULL does. The first post explains how the answers were run and checked.

#Events in

Getting raw events into typed rows, and knowing how dirty they are.

#Q55 · Flatten event JSON, including an array

Medium · Semi-structured data

raw_events.properties is JSON. For purchase events, return one row per purchased item with typed columns: page, order_value (a number), referrer, and from the items array each item's sku and qty. A purchase with no referrer key must still appear.

Sample data

raw_events

event_iduser_idevent_nameproperties
e1u1purchase{"page": "/checkout", "value": 42.5, "referrer": "google", "items": [{"sku": "A-1", "qty": 1}, {"sku": "B-7", "qty": 2}]}
e2u2purchase{"page": "/checkout", "value": "15", "items": [{"sku": "C-3", "qty": 1}]}
e3u1page_view{"page": "/home"}
Sample data as SQL (to run it yourself)
sql
CREATE TABLE raw_events (event_id VARCHAR, user_id VARCHAR, event_name VARCHAR, properties JSON);
INSERT INTO raw_events VALUES
    ('e1', 'u1', 'purchase',  '{"page": "/checkout", "value": 42.5, "referrer": "google", "items": [{"sku": "A-1", "qty": 1}, {"sku": "B-7", "qty": 2}]}'),
    ('e2', 'u2', 'purchase',  '{"page": "/checkout", "value": "15", "items": [{"sku": "C-3", "qty": 1}]}'),
    ('e3', 'u1', 'page_view', '{"page": "/home"}');

Expected output

event_idpageorder_valuereferrerskuqty
e1/checkout42.5googleA-11
e1/checkout42.5googleB-72
e2/checkout15.0NULLC-31
Hint

Scalars come out with a path and a cast. An array needs to be turned into rows: FLATTEN in Snowflake, UNNEST elsewhere.

Answer

In Snowflake:

sql
SELECT
    e.event_id,
    e.properties:page::STRING             AS page,
    TRY_TO_DOUBLE(e.properties:value::STRING) AS order_value,
    e.properties:referrer::STRING         AS referrer,
    f.value:sku::STRING                   AS sku,
    f.value:qty::INT                      AS qty
FROM raw_events e,
     LATERAL FLATTEN(input => e.properties:items) f
WHERE e.event_name = 'purchase';

The same logic in portable SQL, as run on DuckDB to produce the output above:

sql
SELECT
    e.event_id,
    e.properties ->> '$.page'                         AS page,
    TRY_CAST(e.properties ->> '$.value' AS DOUBLE)    AS order_value,
    e.properties ->> '$.referrer'                     AS referrer,
    item ->> '$.sku'                                  AS sku,
    CAST(item ->> '$.qty' AS INT)                     AS qty
FROM raw_events e,
     unnest(CAST(e.properties -> '$.items' AS JSON[])) AS t(item)
WHERE e.event_name = 'purchase'
ORDER BY e.event_id, sku;
  • Path extraction plus a cast: ->> '$.page' (DuckDB and Postgres) or properties:page::STRING (Snowflake) pulls a field out as text, and a cast types it. A missing key, like e2's referrer, comes back as NULL rather than an error.
  • Values arrive with mixed types. e2's value is the string "15" where e1's is a number. TRY_CAST (or Snowflake's TRY_TO_DOUBLE) handles both and nulls out garbage; a plain cast on a JSON field fails or behaves differently per engine.
  • Arrays become rows with a lateral FLATTEN (Snowflake docs) or UNNEST. e1 had two items, so it produces two rows, which changes the grain from one row per event to one row per item. Any event-level number (order_value) now repeats per item, so don't sum it at this grain.
  • Flatten once, at ingestion. Parsing JSON in every downstream query is slow and repeats the type decisions; a typed Silver table does it once.
The sample data before the answer runs.
raw_events
event_iduser_idevent_nameproperties
e1u1purchase{"page": "/checkout", "value": 42.5, "referrer": "google", "items": [{"sku": "A-1", "qty": 1}, {"sku": "B-7", "qty": 2}]}
e2u2purchase{"page": "/checkout", "value": "15", "items": [{"sku": "C-3", "qty": 1}]}
e3u1page_view{"page": "/home"}
Step 1 of 2

Engineer check. Grain: one row per purchased item, not per event. Run twice: read-only. NULL: missing keys are NULL; an event with an empty or missing items array disappears from an inner FLATTEN/UNNEST, so use OUTER => TRUE (Snowflake) or a LEFT JOIN if those events must stay.

#Q56 · How duplicated is the event stream?

Medium · Deduplication metrics

The collector is at-least-once, so event_id can repeat. Before deduplicating, measure it: per app_version, return raw events, unique events and the duplicate percentage, so you can tell whether one client release is misbehaving.

Sample data

raw_events

event_iduser_idapp_version_ingested_at
e1u13.22025-03-01 09:00
e2u13.22025-03-01 09:01
e3u23.32025-03-01 09:02
e3u23.32025-03-01 09:05
e3u23.32025-03-01 09:09
e4u23.32025-03-01 09:03
e4u23.32025-03-01 09:06
e5u33.22025-03-01 09:04
Sample data as SQL (to run it yourself)
sql
CREATE TABLE raw_events (event_id VARCHAR, user_id VARCHAR, app_version VARCHAR, _ingested_at TIMESTAMP);
INSERT INTO raw_events VALUES
    ('e1', 'u1', '3.2', TIMESTAMP '2025-03-01 09:00'), ('e2', 'u1', '3.2', TIMESTAMP '2025-03-01 09:01'),
    ('e3', 'u2', '3.3', TIMESTAMP '2025-03-01 09:02'), ('e3', 'u2', '3.3', TIMESTAMP '2025-03-01 09:05'),
    ('e3', 'u2', '3.3', TIMESTAMP '2025-03-01 09:09'), ('e4', 'u2', '3.3', TIMESTAMP '2025-03-01 09:03'),
    ('e4', 'u2', '3.3', TIMESTAMP '2025-03-01 09:06'), ('e5', 'u3', '3.2', TIMESTAMP '2025-03-01 09:04');

Expected output

app_versionraw_eventsunique_eventsduplicate_pct
3.2330.0
3.35260.0
Hint

The number of duplicates is the raw count minus the distinct count.

Answer
sql
SELECT app_version,
       COUNT(*)                                                         AS raw_events,
       COUNT(DISTINCT event_id)                                         AS unique_events,
       ROUND(100.0 * (COUNT(*) - COUNT(DISTINCT event_id)) / COUNT(*), 1) AS duplicate_pct
FROM raw_events
GROUP BY app_version
ORDER BY app_version;
  • COUNT(*) − COUNT(DISTINCT event_id) is the number of extra copies; dividing by COUNT(*) gives the share of the stream that's duplication.
  • Version 3.3 is 60% duplicates against 0% for 3.2, which points at a retry bug in that release. That's worth an alert, not just a dedup step.
  • Why streams are at-least-once: network retries, mobile apps that buffer offline and resend, and collectors that redeliver after a timeout all produce copies, and exactly-once delivery end to end is rare and expensive. So every model built on raw events dedups by event_id first (QUALIFY ROW_NUMBER() … = 1, post 01), and a metric like this one tells you how much that step is hiding.
The sample data before the answer runs.
raw_events
event_iduser_idapp_version_ingested_at
e1u13.22025-03-01 09:00
e2u13.22025-03-01 09:01
e3u23.32025-03-01 09:02
e3u23.32025-03-01 09:05
e3u23.32025-03-01 09:09
e4u23.32025-03-01 09:03
e4u23.32025-03-01 09:06
e5u33.22025-03-01 09:04
Step 1 of 2

Engineer check. Grain: one row per app version. NULL: COUNT(DISTINCT …) ignores NULL ids, so events without an event_id look like neither unique events nor duplicates; count them separately.

#Sessions and funnels

Turning a stream into visits, and visits into conversion.

#Q57 · Sessionize: a new session after 30 minutes idle

Hard · Sessionization

Assign a per-user session_no to every event, where a new session starts at a user's first event or after more than 30 minutes without one.

Sample data

events

event_iduser_idevent_nameevent_ts
e1u1page_view2025-03-01 09:00
e2u1view_item2025-03-01 09:10
e3u1add_to_cart2025-03-01 09:25
e4u1page_view2025-03-01 10:30
e5u1purchase2025-03-01 10:40
e6u2page_view2025-03-01 11:00
e7u2page_view2025-03-01 14:00
e8u2view_item2025-03-01 14:20
Sample data as SQL (to run it yourself)
sql
CREATE TABLE events (event_id VARCHAR, user_id VARCHAR, event_name VARCHAR, event_ts TIMESTAMP);
INSERT INTO events VALUES
    ('e1', 'u1', 'page_view',   TIMESTAMP '2025-03-01 09:00'),
    ('e2', 'u1', 'view_item',   TIMESTAMP '2025-03-01 09:10'),
    ('e3', 'u1', 'add_to_cart', TIMESTAMP '2025-03-01 09:25'),
    ('e4', 'u1', 'page_view',   TIMESTAMP '2025-03-01 10:30'),
    ('e5', 'u1', 'purchase',    TIMESTAMP '2025-03-01 10:40'),
    ('e6', 'u2', 'page_view',   TIMESTAMP '2025-03-01 11:00'),
    ('e7', 'u2', 'page_view',   TIMESTAMP '2025-03-01 14:00'),
    ('e8', 'u2', 'view_item',   TIMESTAMP '2025-03-01 14:20');

Expected output

user_idevent_idevent_nameevent_tsis_new_sessionsession_no
u1e1page_view2025-03-01 09:0011
u1e2view_item2025-03-01 09:1001
u1e3add_to_cart2025-03-01 09:2501
u1e4page_view2025-03-01 10:3012
u1e5purchase2025-03-01 10:4002
u2e6page_view2025-03-01 11:0011
u2e7page_view2025-03-01 14:0012
u2e8view_item2025-03-01 14:2002
Hint

Three steps: the gap to the previous event, a 1 where a session starts, and a running total of those 1s.

Answer
sql
WITH ordered AS (
    SELECT *, LAG(event_ts) OVER (PARTITION BY user_id ORDER BY event_ts, event_id) AS prev_ts
    FROM events
),
flagged AS (
    SELECT *,
           CASE WHEN prev_ts IS NULL OR event_ts - prev_ts > INTERVAL 30 MINUTE THEN 1 ELSE 0 END AS is_new_session
    FROM ordered
)
SELECT user_id, event_id, event_name, event_ts, is_new_session,
       SUM(is_new_session) OVER (PARTITION BY user_id ORDER BY event_ts, event_id
                                 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS session_no
FROM flagged
ORDER BY user_id, event_ts;
  • LAG(event_ts) gives each event the user's previous event time.
  • The new-session flag is 1 for a user's first event (no previous) or when the gap is over 30 minutes. u1's 10:30 event comes 65 minutes after 09:25, so it starts session 2.
  • SUM(flag) OVER (… ROWS UNBOUNDED PRECEDING …) turns the flags into a counter: 1, 1, 1, 2, 2. This running-sum-of-boundaries trick is the standard way to number islands of rows.
  • ORDER BY event_ts, event_id in both windows makes ties deterministic, and ROWS avoids the peer-row trap from post 03 (Q46).
  • For a globally unique id, combine the user and the number, or hash them.
Each event with the time since the user's previous event. Two gaps are over 30 minutes.
gaps
user_idevent_idevent_nameevent_tsgap
look hereu1e1page_view2025-03-01 09:00NULL
u1e2view_item2025-03-01 09:100:10:00
u1e3add_to_cart2025-03-01 09:250:15:00
look hereu1e4page_view2025-03-01 10:301:05:00
u1e5purchase2025-03-01 10:400:10:00
look hereu2e6page_view2025-03-01 11:00NULL
look hereu2e7page_view2025-03-01 14:003:00:00
u2e8view_item2025-03-01 14:200:20:00
Step 1 of 2

Engineer check. Grain: unchanged, one row per event, now carrying a session number. NULL: the first event's prev_ts is NULL, which the flag handles explicitly; without the IS NULL branch the first session would start at 0.

#Q58 · Session length and bounce rate

Medium · Aggregation

From the sessionized events, compute per session the event count, start, end and duration, and whether it bounced (exactly one event). Then report the overall bounce rate and the average duration of sessions that didn't bounce.

Sample data

events

event_iduser_idevent_nameevent_ts
e1u1page_view2025-03-01 09:00
e2u1view_item2025-03-01 09:10
e3u1add_to_cart2025-03-01 09:25
e4u1page_view2025-03-01 10:30
e5u1purchase2025-03-01 10:40
e6u2page_view2025-03-01 11:00
e7u2page_view2025-03-01 14:00
e8u2view_item2025-03-01 14:20
Sample data as SQL (to run it yourself)
sql
CREATE TABLE events (event_id VARCHAR, user_id VARCHAR, event_name VARCHAR, event_ts TIMESTAMP);
INSERT INTO events VALUES
    ('e1', 'u1', 'page_view',   TIMESTAMP '2025-03-01 09:00'),
    ('e2', 'u1', 'view_item',   TIMESTAMP '2025-03-01 09:10'),
    ('e3', 'u1', 'add_to_cart', TIMESTAMP '2025-03-01 09:25'),
    ('e4', 'u1', 'page_view',   TIMESTAMP '2025-03-01 10:30'),
    ('e5', 'u1', 'purchase',    TIMESTAMP '2025-03-01 10:40'),
    ('e6', 'u2', 'page_view',   TIMESTAMP '2025-03-01 11:00'),
    ('e7', 'u2', 'page_view',   TIMESTAMP '2025-03-01 14:00'),
    ('e8', 'u2', 'view_item',   TIMESTAMP '2025-03-01 14:20');

Expected output

sessionsbounce_rate_pctavg_minutes_engaged
425.018.3
Hint

Group by user and session number, then aggregate the sessions.

Answer
sql
WITH sessionized AS (
WITH ordered AS (
    SELECT *, LAG(event_ts) OVER (PARTITION BY user_id ORDER BY event_ts, event_id) AS prev_ts
    FROM events
),
flagged AS (
    SELECT *,
           CASE WHEN prev_ts IS NULL OR event_ts - prev_ts > INTERVAL 30 MINUTE THEN 1 ELSE 0 END AS is_new_session
    FROM ordered
)
SELECT user_id, event_id, event_name, event_ts, is_new_session,
       SUM(is_new_session) OVER (PARTITION BY user_id ORDER BY event_ts, event_id
                                 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS session_no
FROM flagged
),
sessions AS (
    SELECT user_id, session_no,
           COUNT(*)                         AS events,
           MIN(event_ts)                    AS session_start,
           MAX(event_ts)                    AS session_end,
           datediff('minute', MIN(event_ts), MAX(event_ts)) AS duration_min,
           COUNT(*) = 1                     AS is_bounce
    FROM sessionized
    GROUP BY user_id, session_no
)
SELECT COUNT(*)                                                       AS sessions,
       ROUND(100.0 * AVG(CASE WHEN is_bounce THEN 1 ELSE 0 END), 1)    AS bounce_rate_pct,
       ROUND(AVG(CASE WHEN NOT is_bounce THEN duration_min END), 1)    AS avg_minutes_engaged
FROM sessions;
  • Group by (user_id, session_no) to get one row per session (the grain Q57 created). Its span is MAX − MIN of the event times.
  • A bounce is a session with exactly one event. Its duration is 0 by definition, because there is no second event to measure to.
  • Averaging a 1/0 flag gives the bounce rate: here 1 of 4 sessions (u2's single page view at 11:00), 25%.
  • Exclude bounces from the average duration. Including their zeros drags the average down and mixes two different questions ("how often do people leave immediately?" and "how long do engaged sessions last?").
One row per session.
sessions
user_idsession_noeventssession_startsession_endduration_minis_bounce
u1132025-03-01 09:002025-03-01 09:2525FALSE
u1222025-03-01 10:302025-03-01 10:4010FALSE
look hereu2112025-03-01 11:002025-03-01 11:000TRUE
u2222025-03-01 14:002025-03-01 14:2020FALSE

Engineer check. Grain: the final row summarises all sessions. NULL: AVG of the CASE without an ELSE skips bounces, which is exactly the intent.

#Q59 · An ordered funnel within 24 hours

Hard · Funnels

Count users who completed each step of view_item → add_to_cart → begin_checkout → purchase in order, with the purchase within 24 hours of the first view, and the step-to-step conversion. u3 added to cart before ever viewing an item and must not count as reaching the cart step through that event.

Sample data

events

user_idevent_nameevent_ts
u1view_item2025-03-01 09:00
u1add_to_cart2025-03-01 09:05
u1begin_checkout2025-03-01 09:10
u1purchase2025-03-01 09:12
u2view_item2025-03-01 10:00
u2add_to_cart2025-03-01 10:03
u2begin_checkout2025-03-02 08:00
u2purchase2025-03-02 12:00
u3add_to_cart2025-03-01 08:00
u3view_item2025-03-01 08:30
u4view_item2025-03-01 11:00
Sample data as SQL (to run it yourself)
sql
CREATE TABLE events (user_id VARCHAR, event_name VARCHAR, event_ts TIMESTAMP);
INSERT INTO events VALUES
    ('u1', 'view_item', TIMESTAMP '2025-03-01 09:00'), ('u1', 'add_to_cart', TIMESTAMP '2025-03-01 09:05'),
    ('u1', 'begin_checkout', TIMESTAMP '2025-03-01 09:10'), ('u1', 'purchase', TIMESTAMP '2025-03-01 09:12'),
    ('u2', 'view_item', TIMESTAMP '2025-03-01 10:00'), ('u2', 'add_to_cart', TIMESTAMP '2025-03-01 10:03'),
    ('u2', 'begin_checkout', TIMESTAMP '2025-03-02 08:00'), ('u2', 'purchase', TIMESTAMP '2025-03-02 12:00'),
    ('u3', 'add_to_cart', TIMESTAMP '2025-03-01 08:00'), ('u3', 'view_item', TIMESTAMP '2025-03-01 08:30'),
    ('u4', 'view_item', TIMESTAMP '2025-03-01 11:00');

Expected output

stepnameuserspct_of_previous
1view_item4NULL
2add_to_cart250.0
3begin_checkout2100.0
4purchase150.0
Hint

Find each step's first time after the previous step's time, one step at a time. Independent MINs per event type can't enforce order.

Answer
sql
WITH v AS (SELECT user_id, MIN(event_ts) AS t FROM events WHERE event_name = 'view_item' GROUP BY user_id),
c AS (SELECT v.user_id, MIN(e.event_ts) AS t FROM v JOIN events e
        ON e.user_id = v.user_id AND e.event_name = 'add_to_cart' AND e.event_ts > v.t GROUP BY v.user_id),
k AS (SELECT c.user_id, MIN(e.event_ts) AS t FROM c JOIN events e
        ON e.user_id = c.user_id AND e.event_name = 'begin_checkout' AND e.event_ts > c.t GROUP BY c.user_id),
p AS (SELECT k.user_id, MIN(e.event_ts) AS t FROM k JOIN events e
        ON e.user_id = k.user_id AND e.event_name = 'purchase' AND e.event_ts > k.t GROUP BY k.user_id),
steps AS (
    SELECT 1 AS step, 'view_item' AS name, COUNT(*) AS users FROM v
    UNION ALL SELECT 2, 'add_to_cart', COUNT(*) FROM c
    UNION ALL SELECT 3, 'begin_checkout', COUNT(*) FROM k
    UNION ALL SELECT 4, 'purchase', COUNT(*) FROM p JOIN v USING (user_id)
              WHERE p.t <= v.t + INTERVAL 24 HOUR
)
SELECT step, name, users,
       ROUND(100.0 * users / NULLIF(LAG(users) OVER (ORDER BY step), 0), 1) AS pct_of_previous
FROM steps
ORDER BY step;
  • Each step is found after the previous one: c takes the first cart after the user's first view, k the first checkout after that cart, and so on. A user can only appear in a step's CTE if they appear in the previous one, so the counts can only fall.
  • u3 is the order test. Its only cart came before its view, so it has no cart after a view and stops at step 1. The common shortcut, taking MIN(timestamp) of each event type independently and comparing them, gets this case right too, but it fails a user who carted early, viewed, then carted again: the independent minimum picks the early cart and rejects a real conversion. Chaining the steps handles both.
  • The 24-hour window applies to the whole funnel: u2 bought 26 hours after its first view, so it reaches checkout but not purchase.
  • users / LAG(users) gives step-to-step conversion; NULLIF guards an empty step.
  • For a within-session funnel, add session_no from Q57 to every join.
Each user's first time at each step, each found after the previous step. u3 has no cart after its view.
per user
user_idviewedcartedchecked_outpurchasedwithin_24h
keptu12025-03-01 09:002025-03-01 09:052025-03-01 09:102025-03-01 09:12TRUE
u22025-03-01 10:002025-03-01 10:032025-03-02 08:002025-03-02 12:00FALSE
removedu32025-03-01 08:30NULLNULLNULLNULL
removedu42025-03-01 11:00NULLNULLNULLNULL

Engineer check. Grain: one row per funnel step. NULL: a user missing a step has no row in that step's CTE, so the counts fall naturally; never COUNT a column that can be NULL for users who didn't get that far.

#Users over time

The metrics that depend on rows that aren't there: inactivity, churn, empty days and users who never converted.

#Q60 · Weekly cohort retention

Hard · Cohorts

Group users by the week they signed up. For weeks 0, 1 and 2 after signup, report how many users in each cohort were active, and the percentage of the cohort.

Sample data

users

user_idsignup_ts
u12025-03-03 09:00
u22025-03-04 10:00
u32025-03-05 11:00
u42025-03-10 09:00
u52025-03-12 15:00

events

user_idevent_ts
u12025-03-03 09:05
u12025-03-11 08:00
u12025-03-18 08:00
u22025-03-04 10:05
u22025-03-19 12:00
u32025-03-05 11:05
u42025-03-10 09:05
u42025-03-17 09:00
u52025-03-12 15:05
Sample data as SQL (to run it yourself)
sql
CREATE TABLE users (user_id VARCHAR, signup_ts TIMESTAMP);
INSERT INTO users VALUES
    ('u1', TIMESTAMP '2025-03-03 09:00'), ('u2', TIMESTAMP '2025-03-04 10:00'), ('u3', TIMESTAMP '2025-03-05 11:00'),
    ('u4', TIMESTAMP '2025-03-10 09:00'), ('u5', TIMESTAMP '2025-03-12 15:00');
CREATE TABLE events (user_id VARCHAR, event_ts TIMESTAMP);
INSERT INTO events VALUES
    ('u1', TIMESTAMP '2025-03-03 09:05'), ('u1', TIMESTAMP '2025-03-11 08:00'), ('u1', TIMESTAMP '2025-03-18 08:00'),
    ('u2', TIMESTAMP '2025-03-04 10:05'), ('u2', TIMESTAMP '2025-03-19 12:00'),
    ('u3', TIMESTAMP '2025-03-05 11:05'),
    ('u4', TIMESTAMP '2025-03-10 09:05'), ('u4', TIMESTAMP '2025-03-17 09:00'),
    ('u5', TIMESTAMP '2025-03-12 15:05');

Expected output

cohort_weekcohort_sizewk0wk1wk2wk1_pct
2025-03-03331233.0
2025-03-10221050.0
Hint

Give every activity a week number relative to the user's signup week, then count distinct users per cohort and week number.

Answer
sql
WITH cohort AS (
    SELECT user_id, CAST(date_trunc('week', signup_ts) AS DATE) AS cohort_week
    FROM users
),
activity AS (
    SELECT DISTINCT c.user_id, c.cohort_week,
           datediff('week', c.cohort_week, CAST(date_trunc('week', e.event_ts) AS DATE)) AS week_no
    FROM events e JOIN cohort c USING (user_id)
)
SELECT c.cohort_week,
       COUNT(DISTINCT c.user_id)                                                  AS cohort_size,
       COUNT(DISTINCT CASE WHEN a.week_no = 0 THEN a.user_id END)                 AS wk0,
       COUNT(DISTINCT CASE WHEN a.week_no = 1 THEN a.user_id END)                 AS wk1,
       COUNT(DISTINCT CASE WHEN a.week_no = 2 THEN a.user_id END)                 AS wk2,
       ROUND(100.0 * COUNT(DISTINCT CASE WHEN a.week_no = 1 THEN a.user_id END)
             / COUNT(DISTINCT c.user_id), 0)                                      AS wk1_pct
FROM cohort c
LEFT JOIN activity a USING (user_id)
GROUP BY c.cohort_week
ORDER BY c.cohort_week;
  • The cohort is the signup week, truncated with date_trunc('week', …) (Monday-based in DuckDB, Postgres and by default in Snowflake; check WEEK_START on Snowflake).
  • week_no is how many weeks after the cohort week an activity happened. DISTINCT counts a user once per week however many events they had.
  • COUNT(DISTINCT CASE WHEN week_no = N …) builds the retention triangle: one column per relative week.
  • The LEFT JOIN keeps every user in the cohort's size even if they never came back, which is the denominator retention needs.
  • The 10 March cohort can't have a week 2 yet in data that ends on 19 March, so its 0 is "not yet observable", not "nobody returned". Real retention charts blank out cells that are still in the future.
The sample data before the answer runs.
users
user_idsignup_ts
u12025-03-03 09:00
u22025-03-04 10:00
u32025-03-05 11:00
u42025-03-10 09:00
u52025-03-12 15:00
events
user_idevent_ts
u12025-03-03 09:05
u12025-03-11 08:00
u12025-03-18 08:00
u22025-03-04 10:05
u22025-03-19 12:00
u32025-03-05 11:05
u42025-03-10 09:05
u42025-03-17 09:00
u52025-03-12 15:05
Step 1 of 2

Engineer check. Grain: one row per cohort week. NULL: users with no activity give NULL week_no, which the CASE expressions skip, and they still count in cohort_size.

#Q61 · DAU, 28-day active users and stickiness

Medium · Active users

For 2025-03-28, compute daily active users (DAU), active users in the trailing 28 days including that day (MAU), and stickiness, DAU ÷ MAU.

Sample data

events

user_idevent_ts
u12025-03-28 09:00
u12025-03-28 18:00
u22025-03-28 10:00
u32025-03-15 08:00
u42025-03-01 12:00
u52025-02-28 23:30
Sample data as SQL (to run it yourself)
sql
CREATE TABLE events (user_id VARCHAR, event_ts TIMESTAMP);
INSERT INTO events VALUES
    ('u1', TIMESTAMP '2025-03-28 09:00'), ('u1', TIMESTAMP '2025-03-28 18:00'), ('u2', TIMESTAMP '2025-03-28 10:00'),
    ('u3', TIMESTAMP '2025-03-15 08:00'), ('u4', TIMESTAMP '2025-03-01 12:00'), ('u5', TIMESTAMP '2025-02-28 23:30');

Expected output

daumau_28dstickiness
240.5
Hint

28 days including the target day starts 27 days before it.

Answer
sql
SELECT
    COUNT(DISTINCT CASE WHEN CAST(event_ts AS DATE) = DATE '2025-03-28' THEN user_id END)     AS dau,
    COUNT(DISTINCT CASE WHEN CAST(event_ts AS DATE) BETWEEN DATE '2025-03-28' - 27
                                                         AND DATE '2025-03-28' THEN user_id END) AS mau_28d,
    ROUND(COUNT(DISTINCT CASE WHEN CAST(event_ts AS DATE) = DATE '2025-03-28' THEN user_id END)::DOUBLE
          / NULLIF(COUNT(DISTINCT CASE WHEN CAST(event_ts AS DATE) BETWEEN DATE '2025-03-28' - 27
                                                                    AND DATE '2025-03-28' THEN user_id END), 0), 2) AS stickiness
FROM events;
  • DAU counts distinct users on the day; u1's two events count once.
  • The 28-day window is day − 27 through day, which is 28 days inclusive. Off-by-one windows are the classic bug here: − 28 makes it 29 days and would include u5 from 28 February.
  • Stickiness = DAU ÷ MAU, the share of the month's users who show up on a given day: 2 ÷ 4 = 0.5. The cast to DOUBLE avoids integer division, and NULLIF an empty month.
  • Distinct counts don't add up: you can't sum daily DAUs into MAU, because the same user is active on many days. That's why MAU needs its own distinct count over the window, and Q62 needs a different technique to compute it for every day.
The sample data before the answer runs.
events
user_idevent_ts
u12025-03-28 09:00
u12025-03-28 18:00
u22025-03-28 10:00
u32025-03-15 08:00
u42025-03-01 12:00
u52025-02-28 23:30
Step 1 of 2

Engineer check. Grain: one row for one date. NULL: users with no events in the window simply aren't counted; there's nothing to coalesce.

#Q62 · Rolling 7-day distinct users, for every day

Hard · Windowed distinct counts

For every day from 1 to 10 March, report the number of distinct users active in the trailing 7 days including that day. Days with no activity must still appear.

Sample data

events

user_idevent_ts
u12025-03-01 09:00
u22025-03-01 10:00
u12025-03-03 09:00
u32025-03-05 11:00
u12025-03-08 09:00
u42025-03-10 08:00
Sample data as SQL (to run it yourself)
sql
CREATE TABLE events (user_id VARCHAR, event_ts TIMESTAMP);
INSERT INTO events VALUES
    ('u1', TIMESTAMP '2025-03-01 09:00'), ('u2', TIMESTAMP '2025-03-01 10:00'), ('u1', TIMESTAMP '2025-03-03 09:00'),
    ('u3', TIMESTAMP '2025-03-05 11:00'), ('u1', TIMESTAMP '2025-03-08 09:00'), ('u4', TIMESTAMP '2025-03-10 08:00');

Expected output

as_ofactive_7d
2025-03-012
2025-03-022
2025-03-032
2025-03-042
2025-03-053
2025-03-063
2025-03-073
2025-03-082
2025-03-092
2025-03-103
Hint

A window function can't slide a distinct count in most engines. Join each day to the activity in its own 7-day range instead.

Answer
sql
WITH days AS (
    SELECT CAST(d AS DATE) AS d
    FROM generate_series(DATE '2025-03-01', DATE '2025-03-10', INTERVAL 1 DAY) t(d)
),
active AS (
    SELECT DISTINCT user_id, CAST(event_ts AS DATE) AS d FROM events
)
SELECT days.d AS as_of,
       COUNT(DISTINCT active.user_id) AS active_7d
FROM days
LEFT JOIN active ON active.d BETWEEN days.d - 6 AND days.d
GROUP BY days.d
ORDER BY days.d;
  • Why not a window function: COUNT(DISTINCT user_id) OVER (ORDER BY d ROWS 6 PRECEDING) is rejected by many engines; Postgres, for one, reports that DISTINCT is not implemented for window functions. And summing daily distinct counts over 7 days counts a user once per active day.
  • The spine join: each day in days joins to every activity whose date falls in its 7-day range, and COUNT(DISTINCT user_id) counts unique users per range. u1, active on three days, counts once wherever its days overlap a window.
  • LEFT JOIN from the spine keeps days with no activity in range, with a count of 0.
  • Cost: each activity row joins to 7 days, so the join is 7 times the activity table, which is fine at daily grain. For very large tables, approximate distinct counts (HyperLogLog) are the usual answer.
The sample data before the answer runs.
events
user_idevent_ts
u12025-03-01 09:00
u22025-03-01 10:00
u12025-03-03 09:00
u32025-03-05 11:00
u12025-03-08 09:00
u42025-03-10 08:00
Step 1 of 2

Engineer check. Grain: one row per calendar day. NULL: an empty window gives COUNT(DISTINCT NULL) = 0, the right answer.

#Q63 · First touch and last touch, and the LAST_VALUE trap

Medium · Attribution

For each user who purchased, return the referrer of their first event and of their last event before their first purchase.

Sample data

events

user_idevent_nameevent_tsreferrer
u1page_view2025-03-01 09:00google
u1page_view2025-03-02 09:00newsletter
u1page_view2025-03-03 09:00instagram
u1purchase2025-03-03 09:05NULL
u1page_view2025-03-09 09:00tiktok
u2page_view2025-03-02 10:00bing
u2purchase2025-03-02 10:30NULL
Sample data as SQL (to run it yourself)
sql
CREATE TABLE events (user_id VARCHAR, event_name VARCHAR, event_ts TIMESTAMP, referrer VARCHAR);
INSERT INTO events VALUES
    ('u1', 'page_view', TIMESTAMP '2025-03-01 09:00', 'google'),
    ('u1', 'page_view', TIMESTAMP '2025-03-02 09:00', 'newsletter'),
    ('u1', 'page_view', TIMESTAMP '2025-03-03 09:00', 'instagram'),
    ('u1', 'purchase',  TIMESTAMP '2025-03-03 09:05', NULL),
    ('u1', 'page_view', TIMESTAMP '2025-03-09 09:00', 'tiktok'),
    ('u2', 'page_view', TIMESTAMP '2025-03-02 10:00', 'bing'),
    ('u2', 'purchase',  TIMESTAMP '2025-03-02 10:30', NULL);

Expected output

user_idfirst_touchlast_touch
u1googleinstagram
u2bingbing
Hint

LAST_VALUE looks at the window frame, not the whole partition. What is the default frame when the window has an ORDER BY?

Answer
sql
WITH first_purchase AS (
    SELECT user_id, MIN(event_ts) AS purchase_ts FROM events WHERE event_name = 'purchase' GROUP BY user_id
),
before AS (
    SELECT e.*
    FROM events e JOIN first_purchase fp USING (user_id)
    WHERE e.event_ts < fp.purchase_ts
)
SELECT DISTINCT user_id,
       FIRST_VALUE(referrer) OVER w AS first_touch,
       LAST_VALUE(referrer)  OVER w AS last_touch
FROM before
WINDOW w AS (PARTITION BY user_id ORDER BY event_ts
             ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
ORDER BY user_id;
  • Restrict to events before the first purchase (<, not <=, so the purchase itself, with its NULL referrer, isn't the last touch). u1's tiktok visit after buying doesn't count.
  • The trap: with ORDER BY and no frame, the default frame ends at the current row (RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW). LAST_VALUE then returns each row's own referrer, not the partition's last (step 1). FIRST_VALUE happens to be right with the default frame, which is why the bug hides.
  • The fix is an explicit frame over the whole partition: ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING. The named WINDOW w keeps both functions on the same definition.
  • DISTINCT collapses the per-row repeats to one row per user (QUALIFY ROW_NUMBER() … = 1 works too).
LAST_VALUE with the default frame returns each row's own referrer.
default frame
user_idevent_tsreferrerlast_value_default
wrongu12025-03-01 09:00googlegoogle
wrongu12025-03-02 09:00newsletternewsletter
u12025-03-03 09:00instagraminstagram
u22025-03-02 10:00bingbing
Step 1 of 2

Engineer check. Grain: one row per purchasing user. NULL: a NULL referrer on the last touch is returned as NULL; use LAST_VALUE(referrer IGNORE NULLS) if you want the last known referrer.

#Q64 · Decompose MRR movement

Hard · Revenue metrics

From subscriptions, compute each month's total MRR and split the change from the previous month into new, expansion, contraction and churned MRR, for January to April 2025.

Sample data

subscriptions

user_idmrrstarted_monthcanceled_month
u120.002025-01-012025-02-01
u150.002025-02-01NULL
u230.002025-01-012025-03-01
u340.002025-02-012025-04-01
u325.002025-04-01NULL
u410.002025-03-01NULL
Sample data as SQL (to run it yourself)
sql
CREATE TABLE subscriptions (user_id VARCHAR, mrr DECIMAL(10,2), started_month DATE, canceled_month DATE);
INSERT INTO subscriptions VALUES
    ('u1', 20.00, DATE '2025-01-01', DATE '2025-02-01'),   -- 20 in January
    ('u1', 50.00, DATE '2025-02-01', NULL),                 -- upgraded to 50 from February
    ('u2', 30.00, DATE '2025-01-01', DATE '2025-03-01'),   -- churns in March
    ('u3', 40.00, DATE '2025-02-01', DATE '2025-04-01'),
    ('u3', 25.00, DATE '2025-04-01', NULL),                 -- downgrades in April
    ('u4', 10.00, DATE '2025-03-01', NULL);

Expected output

monthtotal_mrrnew_mrrexpansioncontractionchurned
2025-01-0150.0050.000.000.000.00
2025-02-01120.0040.0030.000.000.00
2025-03-01100.0010.000.000.0030.00
2025-04-0185.000.000.0015.000.00
Hint

A churned user has no subscription row in the month they left, so LAG can't see the drop unless every user has a row in every month.

Answer
sql
WITH months AS (
    SELECT CAST(m AS DATE) AS month
    FROM generate_series(DATE '2025-01-01', DATE '2025-04-01', INTERVAL 1 MONTH) t(m)
),
grid AS (                                        -- every user x every month, zero-filled
    SELECT u.user_id, m.month,
           COALESCE(SUM(s.mrr), 0) AS mrr
    FROM (SELECT DISTINCT user_id FROM subscriptions) u
    CROSS JOIN months m
    LEFT JOIN subscriptions s
           ON s.user_id = u.user_id
          AND m.month >= s.started_month
          AND (s.canceled_month IS NULL OR m.month < s.canceled_month)
    GROUP BY u.user_id, m.month
),
moves AS (
    SELECT *, COALESCE(LAG(mrr) OVER (PARTITION BY user_id ORDER BY month), 0) AS prev
    FROM grid
)
SELECT month,
       SUM(mrr)                                                               AS total_mrr,
       SUM(CASE WHEN prev = 0 AND mrr > 0     THEN mrr        ELSE 0 END)     AS new_mrr,
       SUM(CASE WHEN prev > 0 AND mrr > prev  THEN mrr - prev ELSE 0 END)     AS expansion,
       SUM(CASE WHEN mrr > 0 AND mrr < prev   THEN prev - mrr ELSE 0 END)     AS contraction,
       SUM(CASE WHEN prev > 0 AND mrr = 0     THEN prev       ELSE 0 END)     AS churned
FROM moves
GROUP BY month
ORDER BY month;
  • The user × month grid is the key move. Without it, u2 simply has no row in March, and LAG never sees its MRR fall to 0, so churn is invisible. The grid gives every user a row in every month, with 0 when they paid nothing.
  • month < canceled_month treats the cancellation month as the first month without revenue; pick a convention and keep it.
  • LAG(mrr) compares each user's MRR with last month's, and each change goes in exactly one bucket: from zero is new, up is expansion, down but above zero is contraction, to zero is churned.
  • The bridge must balance: last month's total + new + expansion − contraction − churned = this month's total. February: 50 + 40 + 30 − 0 − 0 = 120. Check it in the pipeline; if it doesn't hold, a subscription overlaps or a month is missing.
The sample data before the answer runs.
subscriptions
user_idmrrstarted_monthcanceled_month
u120.002025-01-012025-02-01
u150.002025-02-01NULL
u230.002025-01-012025-03-01
u340.002025-02-012025-04-01
u325.002025-04-01NULL
u410.002025-03-01NULL
Step 1 of 2

Engineer check. Grain: one row per month. NULL: LAG is NULL in each user's first month; COALESCE(…, 0) makes that month count as new revenue rather than vanishing.

#Q65 · Label every user-month: new, retained, churned or reactivated

Hard · Churn

Using monthly activity from January to April 2025, label each user in each month as new (first active month), retained (active this month and last), reactivated (active again after at least one inactive month), churned (active last month, not this month) or inactive.

Sample data

events

user_idevent_ts
u12025-01-05 09:00
u12025-02-11 09:00
u12025-04-02 09:00
u22025-01-20 10:00
u32025-02-14 08:00
u32025-03-14 08:00
u32025-04-14 08:00
Sample data as SQL (to run it yourself)
sql
CREATE TABLE events (user_id VARCHAR, event_ts TIMESTAMP);
INSERT INTO events VALUES
    ('u1', TIMESTAMP '2025-01-05 09:00'), ('u1', TIMESTAMP '2025-02-11 09:00'), ('u1', TIMESTAMP '2025-04-02 09:00'),
    ('u2', TIMESTAMP '2025-01-20 10:00'),
    ('u3', TIMESTAMP '2025-02-14 08:00'), ('u3', TIMESTAMP '2025-03-14 08:00'), ('u3', TIMESTAMP '2025-04-14 08:00');

Expected output

user_idmonthstatus
u12025-01-01new
u12025-02-01retained
u12025-03-01churned
u12025-04-01reactivated
u22025-01-01new
u22025-02-01churned
u22025-03-01inactive
u22025-04-01inactive
u32025-01-01inactive
u32025-02-01new
u32025-03-01retained
u32025-04-01retained
Hint

You need a row for inactive months to see churn, plus two facts per row: was the user active last month, and were they ever active before.

Answer
sql
WITH months AS (
    SELECT CAST(m AS DATE) AS month FROM generate_series(DATE '2025-01-01', DATE '2025-04-01', INTERVAL 1 MONTH) t(m)
),
active AS (SELECT DISTINCT user_id, CAST(date_trunc('month', event_ts) AS DATE) AS month FROM events),
grid AS (
    SELECT u.user_id, m.month, (a.user_id IS NOT NULL) AS is_active
    FROM (SELECT DISTINCT user_id FROM events) u
    CROSS JOIN months m
    LEFT JOIN active a ON a.user_id = u.user_id AND a.month = m.month
),
flags AS (
    SELECT *,
           LAG(is_active) OVER (PARTITION BY user_id ORDER BY month) AS was_active,
           BOOL_OR(is_active) OVER (PARTITION BY user_id ORDER BY month
                                    ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) AS ever_before
    FROM grid
)
SELECT user_id, month,
       CASE WHEN is_active AND NOT COALESCE(ever_before, FALSE) THEN 'new'
            WHEN is_active AND was_active                       THEN 'retained'
            WHEN is_active                                      THEN 'reactivated'
            WHEN was_active                                     THEN 'churned'
            ELSE                                                     'inactive' END AS status
FROM flags
ORDER BY user_id, month;
  • The month grid (as in Q64) creates rows for inactive months, so the active-to-inactive edge exists to be labelled.
  • was_active (LAG) answers "last month?", and ever_before (BOOL_OR over all earlier rows) answers "ever?". Both are needed: new and reactivated are both "active now, inactive last month", and only ever_before tells them apart. u1 in April is reactivated; u3 in February is new.
  • The CASE order matters: the first matching branch wins, so new is tested before the more general is_active branches.
  • COALESCE(ever_before, FALSE): the frame 1 PRECEDING is empty in each user's first month, so BOOL_OR returns NULL there.
  • On Snowflake, BOOLOR_AGG plays the role of BOOL_OR, or use MAX(CASE WHEN is_active THEN 1 ELSE 0 END).
The sample data before the answer runs.
events
user_idevent_ts
u12025-01-05 09:00
u12025-02-11 09:00
u12025-04-02 09:00
u22025-01-20 10:00
u32025-02-14 08:00
u32025-03-14 08:00
u32025-04-14 08:00
Step 1 of 2

Engineer check. Grain: one row per user per month, exactly one label each. NULL: the first month's LAG and ever_before are NULL; the CASE handles both explicitly.

#Q66 · Month-over-month growth without dividing by zero

Medium · Growth rates

Compute month-over-month growth in monthly active users: (this − last) ÷ last. The first month has no previous month, and a maintenance outage made one month zero. Neither may cause an error or a misleading number.

Sample data

monthly_active

monthactive_users
2025-01-01120
2025-02-01150
2025-03-010
2025-04-01140
Sample data as SQL (to run it yourself)
sql
CREATE TABLE monthly_active (month DATE, active_users INT);
INSERT INTO monthly_active VALUES
    (DATE '2025-01-01', 120), (DATE '2025-02-01', 150), (DATE '2025-03-01', 0), (DATE '2025-04-01', 140);

Expected output

monthactive_usersprev_usersmom_growth_pct
2025-01-01120NULLNULL
2025-02-0115012025.0
2025-03-010150-100.0
2025-04-011400NULL
Hint

NULLIF(x, 0) returns NULL when x is 0. What does dividing by NULL give?

Answer
sql
SELECT month, active_users,
       LAG(active_users) OVER (ORDER BY month) AS prev_users,
       ROUND(100.0 * (active_users - LAG(active_users) OVER (ORDER BY month))
             / NULLIF(LAG(active_users) OVER (ORDER BY month), 0), 1) AS mom_growth_pct
FROM monthly_active
ORDER BY month;
  • LAG gives last month's value; January has none, so its growth is NULL ("no prior period"), not 0.
  • NULLIF(prev, 0) turns a zero denominator into NULL, and dividing by NULL gives NULL instead of an error. April's growth after the zero month is undefined, and NULL says so honestly; an "infinite growth" number would mislead any chart.
  • 100.0 * forces decimal arithmetic. With integers, 30 / 120 is 0 in many engines.
  • March's −100% is real: the outage month dropped to zero. Annotate it rather than hide it.
The sample data before the answer runs.
monthly_active
monthactive_users
2025-01-01120
2025-02-01150
2025-03-010
2025-04-01140
Step 1 of 2

Engineer check. Grain: one row per month. NULL: two different NULLs appear (no previous month, zero previous month); label them in the report if readers need to tell them apart.

#Q67 · Median and p90 time to activation per cohort

Hard · Percentiles

For each signup-month cohort, report the median and 90th-percentile hours from signup to the user's first activation event, the cohort size, and how many never activated.

Sample data

users

user_idsignup_ts
u12025-03-01 09:00
u22025-03-02 09:00
u32025-03-03 09:00
u42025-03-04 09:00
u52025-03-05 09:00
u62025-04-01 09:00

events

user_idevent_nameevent_ts
u1activation2025-03-01 11:00
u2activation2025-03-02 13:00
u3activation2025-03-04 09:00
u3activation2025-03-09 09:00
u4activation2025-03-14 09:00
u6activation2025-04-01 10:00
Sample data as SQL (to run it yourself)
sql
CREATE TABLE users (user_id VARCHAR, signup_ts TIMESTAMP);
INSERT INTO users VALUES
    ('u1', TIMESTAMP '2025-03-01 09:00'), ('u2', TIMESTAMP '2025-03-02 09:00'), ('u3', TIMESTAMP '2025-03-03 09:00'),
    ('u4', TIMESTAMP '2025-03-04 09:00'), ('u5', TIMESTAMP '2025-03-05 09:00'), ('u6', TIMESTAMP '2025-04-01 09:00');
CREATE TABLE events (user_id VARCHAR, event_name VARCHAR, event_ts TIMESTAMP);
INSERT INTO events VALUES
    ('u1', 'activation', TIMESTAMP '2025-03-01 11:00'), ('u2', 'activation', TIMESTAMP '2025-03-02 13:00'),
    ('u3', 'activation', TIMESTAMP '2025-03-04 09:00'), ('u3', 'activation', TIMESTAMP '2025-03-09 09:00'),
    ('u4', 'activation', TIMESTAMP '2025-03-14 09:00'), ('u6', 'activation', TIMESTAMP '2025-04-01 10:00');

Expected output

cohort_monthcohort_sizenever_activatedmedian_hoursp90_hours
2025-03-015114.0175.2
2025-04-01101.01.0
Hint

Use each user's first activation, keep non-activators in the cohort with a LEFT JOIN, and remember what percentile functions do with NULLs.

Answer
sql
WITH first_act AS (
    SELECT user_id, MIN(event_ts) AS activated_ts
    FROM events WHERE event_name = 'activation' GROUP BY user_id
),
hours AS (
    SELECT CAST(date_trunc('month', u.signup_ts) AS DATE)       AS cohort_month,
           datediff('hour', u.signup_ts, f.activated_ts)      AS hours_to_activate
    FROM users u LEFT JOIN first_act f USING (user_id)
)
SELECT cohort_month,
       COUNT(*)                                                         AS cohort_size,
       COUNT(*) - COUNT(hours_to_activate)                              AS never_activated,
       percentile_cont(0.5) WITHIN GROUP (ORDER BY hours_to_activate)   AS median_hours,
       percentile_cont(0.9) WITHIN GROUP (ORDER BY hours_to_activate)   AS p90_hours
FROM hours
GROUP BY cohort_month
ORDER BY cohort_month;
  • MIN(event_ts) takes each user's first activation; u3's second one is ignored.
  • LEFT JOIN keeps u5, who never activated, in the cohort. Its hours are NULL.
  • Percentile functions skip NULLs, so the median and p90 describe only users who activated, while COUNT(*) − COUNT(hours) reports the non-activators separately. Folding them in as some large number would distort the distribution; dropping them would hide the most important fact about the cohort.
  • Percentiles, not the mean: time-to-activation is skewed (most users take hours, a few take weeks), and one slow user moves the mean a lot. Here the median is 14 hours while u4 took 240.
  • percentile_cont interpolates between values; percentile_disc returns an actual observed value. Snowflake and Postgres support both with this syntax.
The sample data before the answer runs.
users
user_idsignup_ts
u12025-03-01 09:00
u22025-03-02 09:00
u32025-03-03 09:00
u42025-03-04 09:00
u52025-03-05 09:00
u62025-04-01 09:00
events
user_idevent_nameevent_ts
u1activation2025-03-01 11:00
u2activation2025-03-02 13:00
u3activation2025-03-04 09:00
u3activation2025-03-09 09:00
u4activation2025-03-14 09:00
u6activation2025-04-01 10:00
Step 1 of 2

Engineer check. Grain: one row per signup month. NULL: non-activators are NULL in the percentile input and counted separately; a cohort where nobody activated returns NULL percentiles, not 0.

#Incremental marts and features

Keeping models current as events arrive twice and late, and preparing features for a model.

#Q68 · An incremental event mart that absorbs redeliveries

Hard · Incremental loads

Load new events into mart_events since the watermark. A redelivered event can arrive in a later batch than its first copy, so deduplicating within the batch isn't enough. Also return the event dates the batch touched, for downstream recomputes.

Sample data

mart_events

event_iduser_idevent_ts
e1u12025-03-01 09:00
e2u22025-03-01 10:00

raw_events

event_iduser_idevent_ts_ingested_at
e1u12025-03-01 09:002025-03-01 09:01
e2u22025-03-01 10:002025-03-01 10:01
e2u22025-03-01 10:002025-03-02 08:00
e3u12025-02-28 22:002025-03-02 08:00
e4u32025-03-02 07:552025-03-02 08:00
e4u32025-03-02 07:552025-03-02 08:00
Sample data as SQL (to run it yourself)
sql
CREATE TABLE mart_events (event_id VARCHAR PRIMARY KEY, user_id VARCHAR, event_ts TIMESTAMP);
INSERT INTO mart_events VALUES ('e1', 'u1', TIMESTAMP '2025-03-01 09:00'), ('e2', 'u2', TIMESTAMP '2025-03-01 10:00');
CREATE TABLE raw_events (event_id VARCHAR, user_id VARCHAR, event_ts TIMESTAMP, _ingested_at TIMESTAMP);
INSERT INTO raw_events VALUES
    ('e1', 'u1', TIMESTAMP '2025-03-01 09:00', TIMESTAMP '2025-03-01 09:01'),
    ('e2', 'u2', TIMESTAMP '2025-03-01 10:00', TIMESTAMP '2025-03-01 10:01'),
    ('e2', 'u2', TIMESTAMP '2025-03-01 10:00', TIMESTAMP '2025-03-02 08:00'),
    ('e3', 'u1', TIMESTAMP '2025-02-28 22:00', TIMESTAMP '2025-03-02 08:00'),
    ('e4', 'u3', TIMESTAMP '2025-03-02 07:55', TIMESTAMP '2025-03-02 08:00'),
    ('e4', 'u3', TIMESTAMP '2025-03-02 07:55', TIMESTAMP '2025-03-02 08:00');
CREATE TABLE etl_control (table_name VARCHAR, watermark_ts TIMESTAMP);
INSERT INTO etl_control VALUES ('mart_events', TIMESTAMP '2025-03-01 23:59');

Expected output

touched_date
2025-02-28
2025-03-01
2025-03-02
Hint

There are two kinds of duplicate: two copies in the same batch, and a copy of something an earlier batch already loaded.

Answer
sql
CREATE OR REPLACE TEMP TABLE batch AS
SELECT event_id, user_id, event_ts
FROM raw_events
WHERE _ingested_at > (SELECT watermark_ts FROM etl_control WHERE table_name = 'mart_events')
QUALIFY ROW_NUMBER() OVER (PARTITION BY event_id ORDER BY _ingested_at) = 1;   -- dupes inside the batch

MERGE INTO mart_events t
USING batch s ON t.event_id = s.event_id                                          -- dupes across batches
WHEN NOT MATCHED THEN INSERT VALUES (s.event_id, s.user_id, s.event_ts);

SELECT DISTINCT CAST(event_ts AS DATE) AS touched_date FROM batch ORDER BY 1;
  • Within the batch: QUALIFY ROW_NUMBER() … = 1 collapses e4's two copies.
  • Across batches: e2 was loaded yesterday and redelivered today. The batch alone can't know that, so a plain INSERT would duplicate it. MERGE … WHEN NOT MATCHED THEN INSERT checks the target and only inserts events it hasn't seen; an insert-only MERGE like this is the standard append-with-dedup.
  • Touched dates come from the batch's event times. e3 happened on 28 February but arrived on 2 March, so 28 February's daily aggregates need recomputing too (Q72).
  • Advance the watermark afterwards with the frozen-bound pattern from post 02 (Q27).
Rows above the watermark, deduplicated within the batch. e2 is a redelivery of an event already in the mart.
batch
event_iduser_idevent_ts
look heree2u22025-03-01 10:00
e3u12025-02-28 22:00
e4u32025-03-02 07:55
Step 1 of 2

Engineer check. Grain: one row per event_id in the mart. Run twice: the MERGE inserts nothing new the second time. NULL: an event with a NULL event_id never matches and is inserted on every run; reject it before the merge.

#Q69 · A per-user feature table for a churn model

Medium · Pivot for ML

Build one row per user with counts of view_item, add_to_cart, purchase and login events in the 30 days up to 2025-03-31, plus days since the user's last event. Users with no events in the window must still appear, with zeros.

Sample data

events

user_idevent_nameevent_ts
u1login2025-03-25 09:00
u1view_item2025-03-25 09:02
u1add_to_cart2025-03-25 09:05
u1purchase2025-03-25 09:09
u2login2025-03-05 10:00
u2view_item2025-03-05 10:01
u2view_item2025-03-06 10:01
u3login2025-01-15 08:00
Sample data as SQL (to run it yourself)
sql
CREATE TABLE users (user_id VARCHAR);
INSERT INTO users VALUES ('u1'), ('u2'), ('u3');
CREATE TABLE events (user_id VARCHAR, event_name VARCHAR, event_ts TIMESTAMP);
INSERT INTO events VALUES
    ('u1', 'login', TIMESTAMP '2025-03-25 09:00'), ('u1', 'view_item', TIMESTAMP '2025-03-25 09:02'),
    ('u1', 'add_to_cart', TIMESTAMP '2025-03-25 09:05'), ('u1', 'purchase', TIMESTAMP '2025-03-25 09:09'),
    ('u2', 'login', TIMESTAMP '2025-03-05 10:00'), ('u2', 'view_item', TIMESTAMP '2025-03-05 10:01'),
    ('u2', 'view_item', TIMESTAMP '2025-03-06 10:01'),
    ('u3', 'login', TIMESTAMP '2025-01-15 08:00');

Expected output

user_idn_viewn_add_to_cartn_purchasen_logindays_since_last_event
u111116
u2200125
u3000075
Hint

Start from the users, not the events, and put the date filter in the join condition rather than the WHERE.

Answer
sql
SELECT u.user_id,
       COUNT(CASE WHEN e.event_name = 'view_item'   THEN 1 END) AS n_view,
       COUNT(CASE WHEN e.event_name = 'add_to_cart' THEN 1 END) AS n_add_to_cart,
       COUNT(CASE WHEN e.event_name = 'purchase'    THEN 1 END) AS n_purchase,
       COUNT(CASE WHEN e.event_name = 'login'       THEN 1 END) AS n_login,
       datediff('day', (SELECT MAX(event_ts) FROM events x WHERE x.user_id = u.user_id),
                TIMESTAMP '2025-03-31 23:59')                     AS days_since_last_event
FROM users u
LEFT JOIN events e
       ON e.user_id = u.user_id
      AND e.event_ts >  TIMESTAMP '2025-03-31 23:59' - INTERVAL 30 DAY
      AND e.event_ts <= TIMESTAMP '2025-03-31 23:59'
GROUP BY u.user_id
ORDER BY u.user_id;
  • Start from users with a LEFT JOIN. u3 had nothing in March, and for a churn model the users with no recent activity are the most important rows. Starting from events would drop them.
  • The window filter is in the ON clause. In the WHERE, it would throw away u3's NULL-joined row and turn the left join back into an inner join, a very common mistake.
  • COUNT(CASE …) pivots event types into feature columns, and counts 0 where there's nothing.
  • Days since last event looks at all history, not just the window: u3 last appeared 75 days before the as-of date, which is the strongest churn signal in the table.
  • A fixed as-of date (not CURRENT_DATE) makes features reproducible: training data must be computed as of the label date, or the model learns from the future.
The sample data before the answer runs.
events
user_idevent_nameevent_ts
u1login2025-03-25 09:00
u1view_item2025-03-25 09:02
u1add_to_cart2025-03-25 09:05
u1purchase2025-03-25 09:09
u2login2025-03-05 10:00
u2view_item2025-03-05 10:01
u2view_item2025-03-06 10:01
u3login2025-01-15 08:00
Step 1 of 2

Engineer check. Grain: one row per user. NULL: users with no events at all would get a NULL days_since_last_event; fill it with a large sentinel or a separate flag before training.

#Q70 · What happens right after add-to-cart?

Hard · Sequences

Within each session, find the event that immediately follows each add_to_cart, and rank the next events by how often they occur. An add_to_cart that ends a session counts as (exit).

Sample data

sessionized

user_idsession_noevent_tsevent_name
u112025-03-01 09:00view_item
u112025-03-01 09:02add_to_cart
u112025-03-01 09:03begin_checkout
u122025-03-02 10:00add_to_cart
u122025-03-02 10:01view_item
u212025-03-01 11:00add_to_cart
u222025-03-01 13:00view_item
u312025-03-01 12:00add_to_cart
u312025-03-01 12:01begin_checkout
Sample data as SQL (to run it yourself)
sql
CREATE TABLE sessionized (user_id VARCHAR, session_no INT, event_ts TIMESTAMP, event_name VARCHAR);
INSERT INTO sessionized VALUES
    ('u1', 1, TIMESTAMP '2025-03-01 09:00', 'view_item'), ('u1', 1, TIMESTAMP '2025-03-01 09:02', 'add_to_cart'),
    ('u1', 1, TIMESTAMP '2025-03-01 09:03', 'begin_checkout'),
    ('u1', 2, TIMESTAMP '2025-03-02 10:00', 'add_to_cart'), ('u1', 2, TIMESTAMP '2025-03-02 10:01', 'view_item'),
    ('u2', 1, TIMESTAMP '2025-03-01 11:00', 'add_to_cart'),
    ('u2', 2, TIMESTAMP '2025-03-01 13:00', 'view_item'),
    ('u3', 1, TIMESTAMP '2025-03-01 12:00', 'add_to_cart'), ('u3', 1, TIMESTAMP '2025-03-01 12:01', 'begin_checkout');

Expected output

next_eventoccurrences
begin_checkout2
(exit)1
view_item1
Hint

LEAD looks one row ahead; partition so it can't look into the next session.

Answer
sql
WITH nxt AS (
    SELECT event_name,
           LEAD(event_name) OVER (PARTITION BY user_id, session_no ORDER BY event_ts) AS next_event
    FROM sessionized
)
SELECT COALESCE(next_event, '(exit)') AS next_event, COUNT(*) AS occurrences
FROM nxt
WHERE event_name = 'add_to_cart'
GROUP BY 1
ORDER BY occurrences DESC, next_event;
  • LEAD(event_name) returns the next event in the partition. Partitioning by (user_id, session_no) stops u2's cart at 11:00 from "leading" to its next session's view_item two hours later, which would be a different visit.
  • The last event of a session has no next, so LEAD returns NULL, which COALESCE labels (exit). Dropping NULLs instead would hide every cart abandonment, often the most important path.
  • The same LEAD-then-group shape builds a full next-step (Markov) table: group by (event_name, next_event) instead of filtering on one event.
The sample data before the answer runs.
sessionized
user_idsession_noevent_tsevent_name
u112025-03-01 09:00view_item
u112025-03-01 09:02add_to_cart
u112025-03-01 09:03begin_checkout
u122025-03-02 10:00add_to_cart
u122025-03-02 10:01view_item
u212025-03-01 11:00add_to_cart
u222025-03-01 13:00view_item
u312025-03-01 12:00add_to_cart
u312025-03-01 12:01begin_checkout
Step 1 of 2

Engineer check. Grain: one row per next event. NULL: the session-ending NULL is the exit signal, so it is kept and labelled.

#Q71 · A referral tree with a cycle in the data

Hard · Recursive CTEs

referrals records who referred whom. Starting from u1, list every user in u1's downstream referral tree with their depth, and the total count. The data has a cycle (a bad import says u5 referred u1, the root of the tree), and the query must not loop forever.

Sample data

referrals

user_idreferred_by
u2u1
u3u1
u4u2
u5u4
u6u3
u1u5
Sample data as SQL (to run it yourself)
sql
CREATE TABLE referrals (user_id VARCHAR, referred_by VARCHAR);
INSERT INTO referrals VALUES
    ('u2', 'u1'), ('u3', 'u1'), ('u4', 'u2'), ('u5', 'u4'), ('u6', 'u3'),
    ('u1', 'u5');   -- bad import: the root u1 'referred by' u5, closing a loop

Expected output

user_iddepth
u21
u31
u42
u62
u53
Hint

Carry the path of visited users through the recursion, and stop when the next user is already on it.

Answer
sql
WITH RECURSIVE tree AS (
    SELECT user_id, 1 AS depth, ['u1', user_id] AS path        -- base: u1's direct referrals
    FROM referrals
    WHERE referred_by = 'u1'
    UNION ALL
    SELECT r.user_id, t.depth + 1, list_append(t.path, r.user_id)
    FROM tree t
    JOIN referrals r ON r.referred_by = t.user_id
    WHERE NOT list_contains(t.path, r.user_id)                   -- cycle guard
      AND t.depth < 20                                           -- depth guard
)
SELECT user_id, MIN(depth) AS depth
FROM tree
GROUP BY user_id
ORDER BY depth, user_id;
  • The base case is u1's direct referrals at depth 1. The recursive step joins the rows found so far to the users they referred, adding 1 to the depth, and repeats until a step adds nothing.
  • The cycle guard: the bad row (u1 referred by u5) closes a loop, u1 → u2 → u4 → u5 → u1 → u2 … Without a guard the recursion would go round it forever, or until the engine's recursion limit stops it with an error. Carrying the path of visited users and refusing to step onto a user already in it breaks the loop. A depth cap is a second safety net.
  • MIN(depth) per user: with bad data a user can be reached by two routes; report the shortest.
  • On Snowflake, build the path with ARRAY_APPEND and test it with ARRAY_CONTAINS; on Postgres, use an array and <> ALL(path), or the CYCLE clause, which Postgres 14 added for exactly this (docs).
The sample data before the answer runs.
referrals
user_idreferred_by
u2u1
u3u1
u4u2
u5u4
u6u3
u1u5
Step 1 of 2

Engineer check. Grain: one row per downstream user. NULL: a NULL referred_by marks a root and never matches the join, which is correct.

#Q72 · Daily active users, new and returning, recomputed incrementally

Expert · Incremental Gold

gold_daily_active has, per date, active users, new users (first-ever activity that day) and returning users. A batch just added events, including a late one for 1 March. Recompute only the touched dates, idempotently, with the new/returning split still correct.

Sample data

gold_daily_active

activity_dateactive_usersnew_usersreturning_users
2025-03-01110
2025-03-02211

mart_events

event_iduser_idevent_ts
e1u12025-03-01 09:00
e2u22025-03-02 10:00
e3u12025-03-02 11:00
e9u22025-03-01 22:00
e10u32025-03-02 12:00

touched

d
2025-03-01
2025-03-02
Sample data as SQL (to run it yourself)
sql
CREATE TABLE mart_events (event_id VARCHAR, user_id VARCHAR, event_ts TIMESTAMP);
INSERT INTO mart_events VALUES
    ('e1', 'u1', TIMESTAMP '2025-03-01 09:00'), ('e2', 'u2', TIMESTAMP '2025-03-02 10:00'),
    ('e3', 'u1', TIMESTAMP '2025-03-02 11:00');
CREATE TABLE gold_daily_active AS
WITH first_seen AS (SELECT user_id, MIN(CAST(event_ts AS DATE)) AS first_day FROM mart_events GROUP BY 1),
day_users AS (SELECT DISTINCT CAST(event_ts AS DATE) AS d, user_id FROM mart_events)
SELECT du.d AS activity_date, COUNT(*) AS active_users,
       COUNT(CASE WHEN fs.first_day = du.d THEN 1 END) AS new_users,
       COUNT(CASE WHEN fs.first_day < du.d THEN 1 END) AS returning_users
FROM day_users du JOIN first_seen fs USING (user_id) GROUP BY du.d;

-- the new batch: u2's late event on 1 March, and u3's first event on 2 March
INSERT INTO mart_events VALUES ('e9', 'u2', TIMESTAMP '2025-03-01 22:00'), ('e10', 'u3', TIMESTAMP '2025-03-02 12:00');
CREATE TABLE touched AS SELECT DATE '2025-03-01' AS d UNION ALL SELECT DATE '2025-03-02';

Expected output

activity_dateactive_usersnew_usersreturning_users
2025-03-01220
2025-03-02312
Hint

A late event can change a user's first day, which moves them from 'new' on one date to 'returning' on another. What must first_seen be computed over?

Answer
sql
DELETE FROM gold_daily_active WHERE activity_date IN (SELECT d FROM touched);

INSERT INTO gold_daily_active
WITH first_seen AS (                           -- over ALL history, not just the batch
    SELECT user_id, MIN(CAST(event_ts AS DATE)) AS first_day
    FROM mart_events GROUP BY user_id
),
day_users AS (
    SELECT DISTINCT CAST(event_ts AS DATE) AS d, user_id
    FROM mart_events
    WHERE CAST(event_ts AS DATE) IN (SELECT d FROM touched)
)
SELECT du.d,
       COUNT(*)                                        AS active_users,
       COUNT(CASE WHEN fs.first_day = du.d THEN 1 END) AS new_users,
       COUNT(CASE WHEN fs.first_day < du.d THEN 1 END) AS returning_users
FROM day_users du
JOIN first_seen fs USING (user_id)
GROUP BY du.d;
  • Recompute only the touched dates, with delete-then-insert, so a re-run replaces the same rows with the same values. The build ran it twice.
  • first_seen is computed over all history. A user's first day is a global fact, not a fact about the batch. Here the late event moves u2's first day from 2 March to 1 March: 1 March gains a new user, and on 2 March u2 changes from new to returning. Both dates are touched, so both are rebuilt correctly.
  • The trap: if a late event changes a user's first day to a date that isn't in the batch's touched list, an earlier date's split goes stale. Late events always touch their own date, which is the date that gains the new user, so the touched list covers this case; but if you ever derive touched differently, add every date where an affected user was previously counted as new.
  • This combines the three incremental patterns of the series: a watermark picks the batch, the touched dates scope the work, and delete-then-insert makes it idempotent.
The sample data before the answer runs.
gold_daily_active
activity_dateactive_usersnew_usersreturning_users
2025-03-01110
2025-03-02211
mart_events
event_iduser_idevent_ts
e1u12025-03-01 09:00
e2u22025-03-02 10:00
e3u12025-03-02 11:00
e9u22025-03-01 22:00
e10u32025-03-02 12:00
touched
d
2025-03-01
2025-03-02
Step 1 of 2

Engineer check. Grain: one row per date. Run twice: verified unchanged. NULL: users whose events all have NULL timestamps have no first day and are dropped by the inner join; reject such events upstream.

#Summary

Product analytics SQL is mostly about rows that aren't there. A churned user has no event in the month they left, so churn and MRR movement need a user × month grid. A day with no activity has no row, so rolling counts need a spine. A user who never activated has no activation time, so percentiles must skip them while the cohort still counts them. A session's last event has no next event, and that missing next is the exit. The other theme is order: sessions come from gaps between ordered events, funnels from steps found one after another, attribution from a window frame that covers the whole partition, and new versus returning from a first day computed over all history.

#Concepts at a glance

ConceptIn one lineWhy it matters
Flatten oncePath, cast, and FLATTEN/UNNEST for arraysTyped rows at a known grain
SessionizationA running sum of "gap > 30 minutes" flagsTurns an event stream into visits
Ordered funnelEach step's first time after the previous stepCounts can only fall, and order is enforced
Cohort triangleDistinct users by signup week and week numberRetention, with not-yet-observable cells
Rolling distinctSpine join, then COUNT(DISTINCT)Window functions can't slide a distinct count
LAST_VALUE frameDefault frame ends at the current rowWiden it to the whole partition
User × month gridZero-filled rows for every user and monthMakes churn and contraction visible
Percentilespercentile_cont, which skips NULLsSkewed durations, with non-converters counted separately
Insert-only MERGEWHEN NOT MATCHED THEN INSERTDedups against what earlier batches loaded
First day over all historyA global fact, not a batch factKeeps new vs returning right under late data

#Test yourself

Why does churn need a user × month grid?

A churned user has no activity in the month they left, so there is no row for LAG to compare. The grid creates a zero row for every inactive month, and the drop from active to zero becomes visible.

LAST_VALUE(referrer) OVER (PARTITION BY user_id ORDER BY event_ts) returns each row's own referrer. Why?

With ORDER BY and no frame, the default frame ends at the current row, so the "last value" is the current row's. Add ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.

An event was loaded yesterday and redelivered in today's batch. Why doesn't deduplicating the batch prevent a duplicate?

The batch holds only one copy, so it looks unique. Only the target knows the event is already there; an insert-only MERGE on event_id checks it.

How do you count distinct users in a trailing 7-day window for every day?

Join a spine of days to the activity in each day's range (activity.d BETWEEN day − 6 AND day) and COUNT(DISTINCT user_id) per day.

The median time to activation should describe users who activated. What should happen to users who never did?

They stay in the cohort with a NULL time; percentile_cont skips them, and the query reports them as a separate count.

#Sources