SQL Event Modeling — Applied Session IDs and Recursive CTEs

ADVEvent ModelingFunnel AnalysisSession IDsCohort RetentionRecursive CTEsPostgreSQL Compatible5 questions
1 / 5 · In progress 0 / 5 completed
QUESTION 1

Funnel Analysis — Calculate sequential conversion rates with MIN(...) FILTER and ordering comparisons

MIN FILTERNULLIFFunnel AnalysisConversion Rates
Background

A funnel is a core event-analysis tool that measures the number of users who pass through multiple steps in sequence, such as view → cart → purchase. A standard pattern is to extract each user's first timestamp for each event with MIN(event_time) FILTER (WHERE event_type='...'), then determine step completion by comparing the timestamps.

-- Get the first occurrence time of each event type per user
MIN(event_time) FILTER (WHERE event_type = 'view')     AS t_view
MIN(event_time) FILTER (WHERE event_type = 'cart')     AS t_cart
MIN(event_time) FILTER (WHERE event_type = 'purchase') AS t_purchase

-- Ordering comparison: confirm that cart happened after view
WHERE t_cart > t_view AND t_purchase > t_cart
Comparisons involving NULL are automatically treated as false:NULL > any value returns NULL, and COUNT(*) FILTER excludes rows whose filter condition is NULL. Thus, a user who never triggered a cart event (t_cart is NULL) is automatically excluded from the step-2 count. SQL's three-valued logic means you do not need to write an explicit IS NOT NULL check.
Problem

From the EC site's user_events table, aggregate a sequential funnel. (1) Determine whether each user completed view → cart → purchase in that order, (2) count the total number of users completing each step, and (3) calculate the view→cart and cart→purchase conversion rates as percentages rounded to one decimal place. Return one row with five columns.

Table used
► user_events (11 rows / 5 users)
user_idevent_typeevent_time
1view2024-01-10 10:00
1cart2024-01-10 10:05
1purchase2024-01-10 10:30
2view2024-01-10 11:00
2cart2024-01-10 11:15
2purchase2024-01-10 11:45
3view2024-01-10 12:00
3cart2024-01-10 12:30
4view2024-01-10 14:00
5view2024-01-10 15:00
5purchase2024-01-10 15:30
Expected Output
step1_viewstep2_cartstep3_purchaseview_to_cart_pctcart_to_purchase_pct
53260.066.7
Model Answer
WITH user_first_steps AS (
  -- FROM: read data from the user-events table
  -- GROUP BY: aggregate at the user level
  SELECT
    user_id,
    MIN(event_time) FILTER (WHERE event_type = 'view')     AS t_view,                    -- MIN() FILTER: get the earliest time (MIN) satisfying the specified condition (event_type) for each user
    MIN(event_time) FILTER (WHERE event_type = 'cart')     AS t_cart,
    MIN(event_time) FILTER (WHERE event_type = 'purchase') AS t_purchase
  FROM   user_events
  GROUP BY user_id
)
SELECT
  COUNT(*) FILTER (WHERE t_view IS NOT NULL)                       AS step1_view,  -- count rows satisfying the condition (IS NOT NULL = has a value)
  COUNT(*) FILTER (WHERE t_cart > t_view)                              AS step2_cart,    -- ordering comparison: check whether cart happened after view
  COUNT(*) FILTER (WHERE t_purchase > t_cart AND t_cart > t_view)    AS step3_purchase,  -- ordering comparison: also check whether purchase happened after cart
  ROUND(100.0 * COUNT(*) FILTER (WHERE t_cart > t_view)                                          -- ROUND(): round to the specified precision (one decimal place here) / multiplying by 100.0 switches to fractional arithmetic and avoids integer division / NULLIF(value1, value2): return NULL when the values are equal; a standard way to prevent division by zero
              / NULLIF(COUNT(*) FILTER (WHERE t_view IS NOT NULL), 0), 1)  AS view_to_cart_pct,
  ROUND(100.0 * COUNT(*) FILTER (WHERE t_purchase > t_cart AND t_cart > t_view)
              / NULLIF(COUNT(*) FILTER (WHERE t_cart > t_view), 0), 1)        AS cart_to_purchase_pct
FROM   user_first_steps;

/*
  Execution order (logical SQL evaluation order):
  1. CTE user_first_steps  → aggregate each user's first event timestamps
  2. Outer query            → conditionally count step completions and calculate conversion rates
  */
Explanation (table transitions & key points)
WITH user_first_steps AS ( SELECT user_id, MIN(event_time) FILTER (WHERE event_type = 'view') AS t_view, MIN(event_time) FILTER (WHERE event_type = 'cart') AS t_cart, MIN(event_time) FILTER (WHERE event_type = 'purchase') AS t_purchase FROM user_events GROUP BY user_id ) SELECT COUNT(*) FILTER (WHERE t_view IS NOT NULL) AS step1_view, COUNT(*) FILTER (WHERE t_cart > t_view) AS step2_cart, COUNT(*) FILTER (WHERE t_purchase > t_cart AND t_cart > t_view) AS step3_purchase, ROUND(100.0 * COUNT(*) FILTER (WHERE t_cart > t_view) / NULLIF(COUNT(*) FILTER (WHERE t_view IS NOT NULL), 0), 1) AS view_to_cart_pct, ROUND(100.0 * COUNT(*) FILTER (WHERE t_purchase > t_cart AND t_cart > t_view) / NULLIF(COUNT(*) FILTER (WHERE t_cart > t_view), 0), 1) AS cart_to_purchase_pct FROM user_first_steps;
LEGEND
Rows read / loaded
① FROM
FROM user_eventsThere are multiple event rows per user. Note that user5 went from view to purchase while skipping cart.
1 / 7
user_idevent_typeevent_time
1view10:00
1cart10:05
1purchase10:30
2view11:00
2cart11:15
2purchase11:45
3view12:00
3cart12:30
4view14:00
5view15:00
5purchase15:30
11 rows read (5 users)
LEARNING POINTS
MIN(event_time) FILTER achieves conditional aggregation in one pass: It retrieves the first timestamp for each event type per user in one table scan. It is faster than self-joins and highly readable. Once t_view, t_cart, and t_purchase are available, step ordering is just a series of inequality comparisons.
Three-valued logic (a comparison with NULL returns NULL) automatically excludes incomplete steps: user5's t_cart is NULL, so t_cart > t_view returns NULL and COUNT(*) FILTER excludes that row. The query works correctly without an explicit IS NOT NULL check.
NULLIF(denominator, 0) is the standard way to prevent division by zero: When the denominator is 0, x / NULLIF(0, 0) = x / NULL = NULL, avoiding an error. A conversion rate for a period with no data remains NULL rather than being misleadingly displayed as 0%, which is useful for downstream BI tools.
ANTI-PATTERNS
The trap of a non-sequential funnel (counting steps independently): If you simply use COUNT(DISTINCT user_id) FILTER (WHERE event_type='cart'), users who carted without viewing first (for example, through an external link) are included. That measures cumulative achievers, not a sequential funnel. State the intended definition clearly in practice.
Conversion rates lost to integer division: 3 / 5 = 0 in PostgreSQL integer division. Convert one side to fractional arithmetic with 100.0 *; ::numeric and * 1.0 have the same effect.
Practical column: Extending the funnel with a time window (conversion within 24 hours)
Production funnels often add a time-window constraint, such as “users who added to cart within 24 hours of viewing.” Replace the condition in this question with t_cart > t_view AND t_cart <= t_view + INTERVAL '24 hours'. The same pattern recurs in retention analysis, campaign measurement, and advertising ROI calculation, making this MIN(...) FILTER plus ordering-comparison pattern a core event-analysis technique.
QUESTION 2

Assign Session IDs — Generate cumulative session numbers with LAG + SUM() OVER (Gaps & Islands)

SUM OVERROWS UNBOUNDEDSession IDsGaps & Islands
Background

After detecting session boundaries (covered in the basic set), the next practical step is assigning session IDs. If a “session-start flag” is cumulatively summed with SUM() OVER (... ROWS UNBOUNDED PRECEDING), every row within a session receives the same number. This is a representative application of SQL's Gaps & Islands pattern.

-- Generate cumulative session numbers
SUM(is_new_session) OVER (
  PARTITION BY user_id
  ORDER BY     event_time
  ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS session_num

-- If is_new_session is [1,0,1,0], the cumulative sum is [1,1,2,2]
--   Rows in the same session receive the same number
What ROWS UNBOUNDED PRECEDING means: It sets the frame from the beginning through the current row. SUM(is_new_session) OVER (... ORDER BY ...) also defaults to UNBOUNDED PRECEDING through CURRENT ROW (technically a RANGE frame), but stating ROWS explicitly is safer and makes the intent clear. Understanding this frame specification is key to mastering window aggregation.
Problem

For the user_events table, treat an event gap of 30 minutes or more as a new-session boundary and assign each event row a session ID in the form user_id-session_num. Return user_id, session_id, event_type, event_time, ordered by user_id and event_time ascending.

Table used
► user_events (8 rows / 3 users)
user_idevent_typeevent_time
1view2024-01-10 10:00
1click2024-01-10 10:05
1view2024-01-10 10:50
1purchase2024-01-10 10:55
2view2024-01-10 11:00
2click2024-01-10 11:10
2view2024-01-10 12:00
3view2024-01-10 09:00
Expected Output
user_idsession_idevent_typeevent_time
11-1view10:00
11-1click10:05
11-2view10:50
11-2purchase10:55
22-1view11:00
22-1click11:10
22-2view12:00
33-1view09:00
Model Answer
WITH with_lag AS (
  -- LAG() OVER: a window function that gets the previous row's value in the specified order (ORDER BY)
  -- PARTITION BY: calculate independently per user (never cross a user boundary)
  SELECT
    user_id, event_type, event_time,
    LAG(event_time) OVER (
      PARTITION BY user_id ORDER BY event_time
    ) AS prev_event_time
  FROM   user_events
),
with_flag AS (
  -- CASE WHEN ... THEN ... ELSE ... END: basic conditional syntax
  -- EXTRACT(EPOCH FROM ...): convert a time interval to a number of seconds (epoch seconds)
  SELECT
    user_id, event_type, event_time,
    CASE
      WHEN prev_event_time IS NULL
        OR EXTRACT(EPOCH FROM (event_time - prev_event_time)) / 60 >= 30
      THEN 1 ELSE 0
    END AS is_new_session
  FROM   with_lag
),
with_session_num AS (
  -- SUM() OVER: calculate the cumulative sum of matching values (here, the session-start flag 1)
  -- ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: the frame from the first row through the current row
  SELECT
    user_id, event_type, event_time,
    SUM(is_new_session) OVER (                                            -- cumulative flag sum = session number
      PARTITION BY user_id
      ORDER BY     event_time
      ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS session_num
  FROM   with_flag
)
SELECT
  user_id,
  user_id || '-' || session_num  AS session_id,                           -- || operator: concatenate strings (for example, '1' || '-' || '1' -> '1-1')
  event_type,
  TO_CHAR(event_time, 'HH24:MI') AS event_time
FROM   with_session_num
ORDER BY user_id, event_time;

/*
  Execution order (logical SQL evaluation order):
  1. CTE with_lag
  2. CTE with_flag
  3. CTE with_session_num
  4. Outer query
  */
Explanation (table transitions & key points)
WITH with_lag AS ( SELECT user_id, event_type, event_time, LAG(event_time) OVER (PARTITION BY user_id ORDER BY event_time) AS prev_event_time FROM user_events ), with_flag AS ( SELECT user_id, event_type, event_time, CASE WHEN prev_event_time IS NULL OR EXTRACT(EPOCH FROM (event_time - prev_event_time))/60 >= 30 THEN 1 ELSE 0 END AS is_new_session FROM with_lag ), with_session_num AS ( SELECT user_id, event_type, event_time, SUM(is_new_session) OVER ( PARTITION BY user_id ORDER BY event_time ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS session_num FROM with_flag ) SELECT user_id, user_id || '-' || session_num AS session_id, event_type, event_time FROM with_session_num ORDER BY user_id, event_time;
LEGEND
Rows read / loaded
① FROM
FROM user_eventsEight event logs from three users. Group consecutive events using the rule “a gap of 30 minutes or more starts a new session,” then assign session IDs.
1 / 6
user_idevent_typeevent_time
1view10:00
1click10:05
1view10:50
1purchase10:55
2view11:00
2click11:10
2view12:00
3view09:00
8 rows read (3 users)
LEARNING POINTS
Generalizing the Gaps & Islands pattern: Set a 0/1 boundary flag and cumulatively sum it to give rows in the same group the same number. This powerful pattern applies far beyond session splitting: grouping consecutive absence days, detecting intervals of inventory-state changes, and partitioning consecutive stock-price increases are all practical uses.
ROWS versus RANGE: ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW defines the frame by physical row count, while RANGE defines it by ORDER BY key values. If multiple rows share an event_time, ROWS processes them one at a time but RANGE processes all rows at that time together. Explicitly use ROWS for cumulative session numbers.
Benefits of a three-stage CTE design: Splitting the query into with_lag → with_flag → with_session_num makes each calculation explicit and debugging easier. To inspect an intermediate state, replace the final query with SELECT * FROM with_flag. In practice, splitting CTEs is more maintainable than one enormous query.
ANTI-PATTERNS
Omitting PARTITION BY lets the cumulative sum cross user boundaries: If you write SUM(is_new_session) OVER (ORDER BY event_time), user1's cumulative sum carries into user2 and produces incorrect session numbers. Always specify PARTITION BY user_id to reset the cumulative sum per user.
A session number alone is not globally unique: user1's session_num=1 and user2's session_num=1 are different sessions. Grouping only by session_num mixes users. Generate a unique key from user_id and session_num, or GROUP BY the composite key (user_id, session_num).
Practical column: From session IDs to session metrics
Once session IDs are available, metrics such as average events per session, session duration, and within-session conversion rate become easy to calculate. For example: SELECT session_id, COUNT(*) AS events, MAX(event_time) - MIN(event_time) AS duration FROM with_session_num GROUP BY session_id. Many standard metrics from Google Analytics and Mixpanel are internally built from this flow: detect session boundaries → assign session IDs → aggregate. Understanding one window-function pattern can open up the world of product analytics.
QUESTION 3

Cohort Retention — Calculate N-month survival rates by signup month with JOIN + DATE_TRUNC

DATE_TRUNCFIRST_VALUE OVERCohort AnalysisRetention Rate
Background

Cohort analysis tracks a group of users who share the same first-use month (signup month) and measures how many remain active over time. Retention, one of the most important metrics for subscription products such as SaaS, mobile apps, and ecommerce, is calculated with this pattern.

-- Determine each user's cohort (first-use month)
SELECT user_id, DATE_TRUNC('month', MIN(event_time))::date AS cohort_month
FROM   user_events GROUP BY user_id

-- Monthly retention for each cohort
SELECT
  cohort_month,
  months_since_signup,
  active_users,
  FIRST_VALUE(active_users) OVER (
    PARTITION BY cohort_month ORDER BY months_since_signup
  ) AS cohort_size              -- initial size of each cohort
The essence is aggregation across two axes (cohort × time): The January signup cohort has counts for January, February, and March; the February cohort has counts for February and March. The result is a two-dimensional cohort table. A heatmap makes it easy to see which user-acquisition efforts succeeded in a given month. FIRST_VALUE() supplies the initial cohort size so relative percentages can be compared.
Problem

From the user_events table, determine each user's first-use month (cohort), then calculate monthly active-user counts and retention rates (%) by cohort. Return cohort_month, months_since_signup, active_users, cohort_size, retention_pct, ordered by cohort_month and months_since_signup ascending.

Table used
► user_events (10 rows / 6 users / 3 months)
user_idevent_time
12024-01-15
12024-02-10
12024-03-05
22024-01-20
22024-02-15
32024-01-25
42024-02-05
42024-03-12
52024-02-20
62024-03-15
Expected Output
cohort_monthmonths_since_signupactive_userscohort_sizeretention_pct
2024-01-01033100.0
2024-01-0112366.7
2024-01-0121333.3
2024-02-01022100.0
2024-02-0111250.0
2024-03-01011100.0
Model Answer
WITH user_cohort AS (
  SELECT                                                                                     -- first month per user = cohort
    user_id,
    DATE_TRUNC('month', MIN(event_time))::date  AS cohort_month  -- truncate to month → DATE type
  FROM   user_events
  GROUP BY user_id
),
user_activity AS (
  SELECT DISTINCT                                                                            -- months when each user was active (deduplicated)
    user_id,
    DATE_TRUNC('month', event_time)::date  AS activity_month
  FROM   user_events
),
cohort_join AS (
  SELECT                                                                                     -- attach elapsed months to each activity row
    uc.cohort_month,
    ua.activity_month,
    ua.user_id,
    ((EXTRACT(YEAR FROM ua.activity_month) - EXTRACT(YEAR FROM uc.cohort_month)) * 12  -- months elapsed since signup (year difference × 12 + month difference)
      + (EXTRACT(MONTH FROM ua.activity_month) - EXTRACT(MONTH FROM uc.cohort_month)))::int
        AS months_since_signup
  FROM        user_cohort   uc                                                               -- USING (user_id): shorthand JOIN on the column shared by both tables
  JOIN        user_activity ua USING (user_id)
)
SELECT
  cohort_month,
  months_since_signup,
  COUNT(DISTINCT user_id)            AS active_users,                                        -- number of active users in each month
  FIRST_VALUE(COUNT(DISTINCT user_id)) OVER (                      -- FIRST_VALUE() OVER: get the value in the first row of the specified order / the first value by elapsed month is the initial cohort size
    PARTITION BY cohort_month ORDER BY months_since_signup
  )                                       AS cohort_size,
  ROUND(100.0 * COUNT(DISTINCT user_id)                            -- calculate retention as active users / initial cohort size
       / FIRST_VALUE(COUNT(DISTINCT user_id)) OVER (
           PARTITION BY cohort_month ORDER BY months_since_signup
         ), 1)                        AS retention_pct
FROM     cohort_join
GROUP BY cohort_month, months_since_signup
ORDER BY cohort_month, months_since_signup;

/*
  Execution order (logical SQL evaluation order):
  1. CTE user_cohort
  2. CTE user_activity
  3. CTE cohort_join
  4. Outer query
  5. 100.0 * active / cohort_size  → retention rate (%)
  */
Explanation (table transitions & key points)
WITH user_cohort AS ( SELECT user_id, DATE_TRUNC('month', MIN(event_time))::date AS cohort_month FROM user_events GROUP BY user_id ), user_activity AS ( SELECT DISTINCT user_id, DATE_TRUNC('month', event_time)::date AS activity_month FROM user_events ), cohort_join AS ( SELECT uc.cohort_month, ua.activity_month, ua.user_id, ((EXTRACT(YEAR FROM ua.activity_month) - EXTRACT(YEAR FROM uc.cohort_month)) * 12 + (EXTRACT(MONTH FROM ua.activity_month) - EXTRACT(MONTH FROM uc.cohort_month)))::int AS months_since_signup FROM user_cohort uc JOIN user_activity ua USING (user_id) ) SELECT cohort_month, months_since_signup, COUNT(DISTINCT user_id) AS active_users, FIRST_VALUE(COUNT(DISTINCT user_id)) OVER ( PARTITION BY cohort_month ORDER BY months_since_signup ) AS cohort_size, ROUND(100.0 * COUNT(DISTINCT user_id) / FIRST_VALUE(COUNT(DISTINCT user_id)) OVER ( PARTITION BY cohort_month ORDER BY months_since_signup ), 1) AS retention_pct FROM cohort_join GROUP BY cohort_month, months_since_signup ORDER BY cohort_month, months_since_signup;
LEGEND
Rows read / loaded
① FROM
FROM user_eventsTen event logs from six users across three months (January–March). Split these into first-use-month cohorts and monthly activity, then join them to build the two-dimensional cohort table.
1 / 7
user_idevent_time
12024-01-15
12024-02-10
12024-03-05
22024-01-20
22024-02-15
32024-01-25
42024-02-05
42024-03-12
52024-02-20
62024-03-15
10 rows read
LEARNING POINTS
Three-stage “define cohort → expand activity → JOIN” architecture: Cohort SQL can be cleanly structured with three CTEs: define one row per user's first-use month, define one row per user-active month with DISTINCT, then JOIN the two and calculate elapsed months. This division of responsibilities keeps complex aggregation queries maintainable.
FIRST_VALUE() OVER expands the first value in a frame to every row: FIRST_VALUE(active_users) OVER (PARTITION BY cohort_month ORDER BY months_since_signup) copies month 0's value (= initial cohort size) to every row in that cohort. Carrying the denominator on every row is a standard pattern for retention, share, and contribution metrics.
EXTRACT formula for integer month differences: (year difference × 12) + month difference is a standard way to get elapsed time in whole months. AGE() also exists, but it returns an interval that can be awkward for integer comparisons and ORDER BY, so direct EXTRACT arithmetic is often preferred in practice.
ANTI-PATTERNS
Joining duplicate activity rows by forgetting SELECT DISTINCT: Without DISTINCT in user_activity, a user with multiple events in one month produces multiple rows. COUNT(DISTINCT user_id) remains correct after the JOIN, but the query becomes unnecessarily heavy. Normalize with DISTINCT before joining.
Using a subquery instead of FIRST_VALUE: A subquery such as (SELECT active_users FROM ... WHERE months_since_signup = 0) calculates one cohort at a time and is slower. A window function processes every cohort in one pass, making the difference visible at production scale.
Practical column: Retention analysis reveals a product's true value
The shape of a retention curve (months on the x-axis, percent on the y-axis) tells a compelling story about a product's core value. A curve that drops sharply in the first months and then flattens indicates a healthy core of retained users; a curve that keeps falling toward zero suggests product–market fit has not been reached. Andrew Chen's blog is often cited for the benchmark that 12-month SaaS retention above 30% is excellent. Cohort slicing also makes it possible to measure the time-series impact of new features and onboarding improvements.
QUESTION 4

Behavioral-Path Aggregation — Extract common user paths with STRING_AGG

STRING_AGGARRAY_AGGTwo-stage aggregationPath Analysis
Background

In marketing and UX improvement, the path showing “which events a user passed through, and in what order” is extremely valuable. For example, view → cart → purchase and view → purchase have very different contexts even though both end in purchase.

This question teaches two-stage aggregation with STRING_AGG (or ARRAY_AGG). First, turn each user's events into a time-ordered string; second, count users who share the same path. This extracts the Top N most common behavioral patterns.

-- Concatenate events in chronological order for each user
STRING_AGG(event_type, ' → ' ORDER BY event_time)
Why specify ORDER BY inside STRING_AGG: Putting ORDER BY inside the aggregate guarantees the order of the concatenated string. If you omit it, the order may change between executions.
Problem

Write a query that concatenates each user's events in firing order with “ → ”, then aggregates the user count and path length for each identical path.
First generate one path string per user by concatenating events chronologically, then count users per path. Sort by descending user count; ties are sorted by descending path length.

Table used
► events (12 rows / 5 users)
user_idevent_typeevent_time
1view2024-01-15 10:00:00
1cart2024-01-15 10:05:00
1purchase2024-01-15 10:10:00
2view2024-01-15 11:00:00
2cart2024-01-15 11:03:00
2purchase2024-01-15 11:08:00
3view2024-01-15 12:00:00
3cart2024-01-15 12:02:00
4view2024-01-15 13:00:00
4purchase2024-01-15 13:01:00
5view2024-01-15 14:00:00
5purchase2024-01-15 14:05:00
Expected Output
pathuser_countpath_length
view → cart → purchase23
view → purchase22
view → cart12
Model Answer
WITH user_paths AS (
  -- Stage 1: group by user and concatenate events chronologically
  SELECT
    user_id,
    STRING_AGG(event_type, ' → ' ORDER BY event_time) AS path,      -- STRING_AGG(column, delimiter ORDER BY order): concatenate strings while guaranteeing order
    COUNT(*)                                        AS path_length  -- COUNT(*): count the total events generated by the user
  FROM   events
  GROUP BY user_id
)
-- Stage 2: aggregate user counts by identical path (behavioral pattern)
SELECT
  path,
  COUNT(*)         AS user_count,                                   -- COUNT(*) counts rows grouped by path (= users sharing the path)
  MAX(path_length) AS path_length                                   -- MAX(): maximum in the group; for an identical path, MAX() and MIN() give the same length
FROM   user_paths
GROUP BY path
ORDER BY user_count DESC, path_length DESC;                         -- ORDER BY column DESC: sort from larger values to smaller values

/*
  Execution order (logical SQL evaluation order):
  1. CTE user_paths
  2. Outer query
  */
Explanation (table transitions & key points)
WITH user_paths AS ( SELECT user_id, STRING_AGG(event_type, ' → ' ORDER BY event_time) AS path, COUNT(*) AS path_length FROM events GROUP BY user_id ) SELECT path, COUNT(*) AS user_count, MAX(path_length) AS path_length FROM user_paths GROUP BY path ORDER BY user_count DESC, path_length DESC;
LEGEND
Rows read / loaded
STEP 1
events tableTwelve event logs from five users. Group by user_id and concatenate event names in event_time order.
1 / 6
user_idevent_typeevent_time
1view10:00:00
1cart10:05:00
1purchase10:10:00
2view11:00:00
2cart11:03:00
2purchase11:08:00
3view12:00:00
3cart12:02:00
4view13:00:00
4purchase13:01:00
5view14:00:00
5purchase14:05:00
12 rows / 5 users
LEARNING POINTS
ORDER BY inside STRING_AGG guarantees ordered concatenation: Writing STRING_AGG(event_type, ' → ' ORDER BY event_time) explicitly sets the aggregation order. Omitting it can produce a broken path such as “view → purchase → cart,” making the analysis meaningless. PostgreSQL's ARRAY_AGG(event_type ORDER BY event_time) creates a structured array for further processing.
The two-stage “individual paths → path aggregation” pattern: Finding “behavioral trajectory → trajectory popularity” requires two GROUP BY stages. The first compresses one user's behavior into one row by user_id; the second aggregates patterns by path. This hierarchy applies to A/B-test pattern classification, purchase-path analysis, and screen-transition analysis.
Replacing STRING_AGG with ARRAY_AGG expands analysis options: PostgreSQL supports ARRAY_AGG(event_type ORDER BY event_time), followed by operations such as array_length, arr[1] (first element), and arr && ARRAY['cart'] (contains an element). Remember: strings are for display; arrays are for analysis.
ANTI-PATTERNS
Forgetting ORDER BY in STRING_AGG: Aggregate functions process unordered sets, so STRING_AGG(event_type, ' → ') may return a different order on each execution. It may appear to work by insertion or physical order, but production queries must include ORDER BY inside the argument.
Ignoring dialect differences among GROUP_CONCAT and LISTAGG: MySQL uses GROUP_CONCAT(event_type ORDER BY event_time SEPARATOR ' → '), while Oracle uses LISTAGG(event_type, ' → ') WITHIN GROUP (ORDER BY event_time). Always check the dialect matrix when porting SQL.
Practical column: The relationship between path analysis and Sankey diagrams
Aggregated behavioral paths are often visualized as Sankey diagrams, where flow volume is represented by line width. Google Analytics Behavior Flow, Mixpanel Funnel/Flow, and Amplitude Pathfinder all fundamentally execute this SQL logic internally. The challenge is explosive path cardinality, so production analysis commonly limits the first N events or filters to major event types.
QUESTION 5

Fill Missing Dates with a Recursive CTE — Visualize days without events as 0

WITH RECURSIVELEFT JOINCOALESCEDate Series
Background

When charting daily active users (DAU), a day with zero events may be absent from the data, causing a line chart to break unnaturally. The standard solution is to generate a date-series table containing every day in the analysis period and LEFT JOIN the real data to it.

This question dynamically generates a date series with WITH RECURSIVE. Recursive CTEs are difficult to grasp, so the explanation visualizes three things at every step: the current accumulated result, the row just added, and the next row to generate.

-- Basic WITH RECURSIVE structure
WITH RECURSIVE date_series AS (
  SELECT DATE '2024-01-10' AS day          -- base case (first row)
  UNION ALL
  SELECT day + 1            -- recursive term (+1 day from the previous row)
  FROM date_series
  WHERE day < DATE '2024-01-15'            -- termination condition
)
How recursion proceeds: The base case runs first, then the recursive term is evaluated with its result as input. As long as a new result is returned, the recursive term repeats for the row just added. Without a WHERE termination condition, the query loops forever.
Problem

For the six-day period from 2024-01-10 through 2024-01-15 (inclusive), aggregate daily DAU (distinct users) and output days with no events as 0.
Because aggregating only the events table drops empty days, generate a continuous date series with a recursive CTE or similar technique, then join it to the aggregate to fill missing dates.

Table used
► events (6 rows / 4 days)
user_idevent_time
12024-01-10 09:00
22024-01-10 14:00
12024-01-12 10:00
32024-01-12 16:00
22024-01-14 11:00
12024-01-15 09:30
Expected Output
dayactive_users
2024-01-102
2024-01-110
2024-01-122
2024-01-130
2024-01-141
2024-01-151
Model Answer
-- WITH RECURSIVE: declare a recursive CTE (self-reference becomes possible)
WITH RECURSIVE date_series AS (
  -- Stage 1: base case — return the start date as one row
  SELECT DATE '2024-01-10' AS day
  -- UNION ALL: combine SELECT results vertically while preserving duplicates
  UNION ALL
  -- Stage 2: recursive term — add one day to the most recent result
  SELECT day + 1
  FROM   date_series
  WHERE  day < DATE '2024-01-15'                         -- continue only while day is before 2024-01-15
),
daily_dau AS (
  -- truncate event times to days and count distinct users per day
  SELECT
    DATE_TRUNC('day', event_time)::DATE AS day,
    COUNT(DISTINCT user_id)             AS active_users  -- DISTINCT user_id: count each user only once
  FROM   events
  GROUP BY DATE_TRUNC('day', event_time)::DATE
)
-- LEFT JOIN from the date series and turn unmatched-day NULLs into 0
SELECT
  ds.day,
  COALESCE(dd.active_users, 0) AS active_users           -- COALESCE(value1, value2): return value2 when value1 is NULL; standard missing-value fill
FROM      date_series ds                                 -- LEFT JOIN: keep every left-table row; join only matching right-table rows
LEFT JOIN daily_dau   dd ON ds.day = dd.day
ORDER BY  ds.day;

/*
  Execution order (logical SQL evaluation order):
  1. CTE date_series (WITH RECURSIVE)
  2. CTE daily_dau
  3. Outer query
  */
Explanation (table transitions & key points)
WITH RECURSIVE date_series AS ( SELECT DATE '2024-01-10' AS day UNION ALL SELECT day + 1 FROM date_series WHERE day < DATE '2024-01-15' ), daily_dau AS ( SELECT DATE_TRUNC('day', event_time)::DATE AS day, COUNT(DISTINCT user_id) AS active_users FROM events GROUP BY DATE_TRUNC('day', event_time)::DATE ) SELECT ds.day, COALESCE(dd.active_users, 0) AS active_users FROM date_series ds LEFT JOIN daily_dau dd ON ds.day = dd.day ORDER BY ds.day;
LEGEND
Rows read / loaded
STEP 1
events table (sparse)Six event logs. There are no events on 01-11 or 01-13, so this table alone cannot represent days with zero DAU.
1 / 11
user_idevent_time
12024-01-10 09:00
22024-01-10 14:00
12024-01-12 10:00
32024-01-12 16:00
22024-01-14 11:00
12024-01-15 09:30
6 rows / 4 days (01-11, 01-13 missing)
LEARNING POINTS
A recursive CTE repeats “accumulated result + latest row → add one row”: The left side of UNION ALL (the base case) is evaluated once; the right side (the recursive term) repeatedly uses the row just added. Each iteration ends when WHERE becomes false. This is ideal for date series, hierarchies, and sequences such as Fibonacci numbers up to N.
Date series × LEFT JOIN × COALESCE is the standard three-part gap-filling pattern: Put a continuous reference axis on the left, LEFT JOIN the data table, and convert missing values to 0 or a default with COALESCE. This appears in time-series dashboards, SLA calculations, and absence tracking. PostgreSQL's generate_series('2024-01-10'::date, '2024-01-15'::date, INTERVAL '1 day') is a dedicated alternative, but WITH RECURSIVE is especially valuable for understanding the concept.
A termination condition is mandatory: Without WHERE day < '2024-01-15', the recursive term generates rows forever and can exhaust memory or crash the database. Always include a condition that becomes false after a finite number of iterations. Because this query feeds the latest added row back into the recursion, its date increment naturally reaches the end.
ANTI-PATTERNS
An INNER JOIN removes zero-activity days: Writing JOIN (= INNER JOIN) drops 01-11 and 01-13 because they are absent from daily_dau. Put the side whose full period must be preserved on the left of LEFT JOIN.
Filtering right-side columns in WHERE turns LEFT JOIN into INNER JOIN: A condition such as WHERE dd.active_users IS NOT NULL removes unmatched rows, effectively undoing the LEFT JOIN. Put right-side conditions in ON, or filter the source before the FROM clause.
A recursive CTE without a termination condition loops forever: Omitting WHERE or writing a condition that never ends creates an infinite loop. Before production, verify that the initial accumulated row can eventually make WHERE false and test with a small range.
Practical column: dbt and the date-dimension-table best practice
In a production warehouse, it is standard to materialize one date-dimension table rather than write a recursive CTE in every query. A table with columns such as day, year, month, day_of_week, is_weekend, fiscal_quarter, ... can serve as the JOIN axis for nearly every time-series query. dbt packages such as dbt_date and dbt_utils.date_spine implement this idea internally. Understanding recursive CTEs helps explain why a date dimension is useful.