Behavioral Analytics — Learn Window Functions, Recursive CTEs, and Multi-Period Analysis in Practice
AdvancedBehavioral analyticsWindow functionsROW_NUMBER / NTILE / LAGRecursive CTE / UNNESTPostgreSQL-compatible5 questions
QUESTION 1
Window Function ROW_NUMBER() — Extract the First Purchase Record for Each User
ROW_NUMBERPARTITION BYPurchase analysisFirst-event extraction
Background

Window functions add information such as a rank or aggregate within a group to each row without collapsing rows like GROUP BY. ROW_NUMBER() assigns consecutive numbers starting at 1 in ORDER BY order within each partition.

ROW_NUMBER() OVER (
  PARTITION BY user_id            -- Number rows independently for each user
  ORDER BY     purchased_at ASC  -- Number from oldest to newest (rn=1 is first)
)
ROW_NUMBER vs RANK vs DENSE_RANK: When timestamps tie exactly, ROW_NUMBER still assigns unique numbers (which row receives 1 is unspecified). RANK gives tied rows the same rank and skips the next rank. To extract exactly one first event, ROW_NUMBER + WHERE rn = 1 is the safest pattern.
Problem

From the purchase_events table, extract each user's first purchase record (user_id, first_item, first_purchase_date). Use a CTE to assign each row a number (rn) ordered by purchased_at from oldest to newest, then filter to rn=1 in the outer query. Return the rows in ascending user_id order.

Tables used
► purchase_events (7 rows)
user_iditem_idpurchased_at
1A0012024-01-05
2B0012024-01-10
1C0022024-01-15
3F0022024-01-18
3D0012024-01-20
2E0032024-02-01
1G0042024-02-10

✱ user1=3 purchases / user2=2 / user3=2. Extract only the first purchase for each user.

Expected Output

Expected output (ascending user_id):

user_idfirst_itemfirst_purchase_date
1A0012024-01-05
2B0012024-01-10
3F0022024-01-18

user1: A001 is first (01-05). user2: B001 is first (01-10). user3: F002 is first (01-18, earlier than D001 on 01-20).

QUESTION 2
Window Function NTILE() — Score Users on a Four-Level Purchase-Frequency Scale
NTILECTEUser segmentationFrequency score
Background

NTILE(n) is a window function that divides all rows into n buckets and assigns each row a bucket number (1–n). It is often used for Frequency scores in RFM analysis and for automatically classifying user engagement levels.

NTILE(4) OVER (ORDER BY purchase_count ASC)
-- Split 5 users into 4 buckets: [2 rows, 1 row, 1 row, 1 row]
-- The first bucket receives the extra row (bucket1 has 2 users)
-- ORDER BY ASC → smaller values go to tile1 = low score
NTILE bucketing rule: When the row count is not divisible by the number of buckets, the first buckets receive one extra row each. Splitting 5 rows into 4 buckets gives (5÷4=1 remainder 1) → bucket1 has 2 rows and buckets2–4 have 1 each. With ORDER BY ASC, smaller values go into lower buckets, so the highest score means the highest-frequency users (bucket4).
Problem

From the purchases table, aggregate purchase counts per user and assign a four-level frequency score (freq_score) with NTILE(4). Also flag users with freq_score=4 as is_high_value = true, and return the result in ascending user_id order. Output columns: user_id, purchase_count, freq_score, is_high_value.

Tables used
► purchases (15 rows)
user_idorder_idordered_at
11012024-01-05
11022024-01-20
11032024-02-10
21042024-01-08
21052024-02-05
31062024-01-12
31072024-01-18
31082024-01-24
31092024-02-02
31102024-02-15
41112024-01-30
51122024-01-09
51132024-01-16
51142024-02-08
51152024-02-20

✱ user1=3 purchases / user2=2 / user3=5 / user4=1 / user5=4

Expected Output

Expected output (ascending user_id):

user_idpurchase_countfreq_scoreis_high_value
132false
221false
354true
411false
543false

Split 5 rows into 4 buckets: user4 (1 purchase) and user2 (2) → tile1, user1 (3) → tile2, user5 (4) → tile3, user3 (5) → tile4. Because the extra row goes to tile1, user4 and user2 share score 1.

QUESTION 3
Window Function LAG() — Calculate Elapsed Days Between Steps and Judge Time-Bounded Conversion
LAGPARTITION BYFunnel analysisTime-bounded CVR
Background

LAG(col) retrieves the value from the previous row in a window. The first row in each partition becomes NULL.

LAG(stepped_at) OVER (
  PARTITION BY user_id         -- Evaluate each user independently
  ORDER BY     stepped_at ASC  -- Chronological order
) AS prev_stepped_at
-- The first row (page_view) is NULL; the next row (signup) receives page_view's timestamp
date - date returns an integer (days): In PostgreSQL, subtracting date - date returns an integer number of days. By contrast, timestamp - timestamp returns an interval. You can compare dates directly with stepped_at - prev_stepped_at <= 7 and express a within-seven-day conversion check in one line.
Problem

From the step_events table, calculate the elapsed days from the previous step (page_view) to each user's signup step and whether the conversion occurred within 7 days. Use LAG() to retrieve the previous timestamp, then keep only signup rows with WHERE. Output columns: user_id, stepped_at, prev_stepped_at, days_from_prev, within_7days (ascending user_id).

Tables used
► step_events (8 rows)
user_idstepstepped_at
1page_view2024-01-01
1signup2024-01-03
2page_view2024-01-05
2signup2024-01-07
3page_view2024-01-10
3signup2024-01-18
4page_view2024-01-15
4signup2024-01-16

✱ user3 takes 8 days from page_view to signup → outside the seven-day conversion window

Expected Output

Expected output (ascending user_id):

user_idstepped_atprev_stepped_atdays_from_prevwithin_7days
12024-01-032024-01-012true
22024-01-072024-01-052true
32024-01-182024-01-108false
42024-01-162024-01-151true

Only user3 signs up after 8 days and falls outside the conversion window. The other three users are within 7 days.

QUESTION 4
UNNEST(ARRAY[]) + CROSS JOIN — Generate a Multi-Month Retention Matrix in One Query
UNNESTCROSS JOINRetention analysisMulti-period
Background

In basic Q2, we calculated retention for only one following month with INTERVAL '1 month'. Combining UNNEST(ARRAY[1,2,3]) with CROSS JOIN generates Month 1–3 retention for all periods in one query, without a loop.

-- Expand an array into rows with UNNEST
SELECT UNNEST(ARRAY[1, 2, 3]) AS offset_month
-- → 3 rows: offset=1, offset=2, offset=3

-- Generate a dynamic INTERVAL
cohort_month + (offset_month || ' month')::interval
-- offset=1 → cohort_month + 1 month → target month
CROSS JOIN generates the Cartesian product (all combinations): 2 cohorts × 3 offsets = 6 cohort-period combinations. Because every cohort gets a row for every period, an absent active month at offset=3 still leaves a retained=0 row rather than deleting the row. LEFT JOIN + GROUP BY covers the zero-match case.
Problem

From the users and login_events tables, calculate a cohort-based Month 1–3 retention-rate matrix. Use UNNEST to generate three rows for offset=1,2,3, CROSS JOIN to expand every cohort×offset combination, and LEFT JOIN to attach active months. Output columns: cohort_month, offset_month, cohort_size, retained, retention_pct (ascending cohort_month, then offset_month).

Tables used
► users (6 rows)
user_idregistered_at
12024-01-10
22024-01-15
32024-01-22
42024-02-05
52024-02-14
62024-02-20
► login_events (12 rows)
user_idevent_date
12024-01-12
22024-01-18
32024-01-25
12024-02-05
32024-02-10
42024-02-07
52024-02-14
62024-02-22
12024-03-05
42024-03-08
62024-03-15
42024-04-03

✱ January cohort: M1=user1,3 / M2=user1 / M3=none. February cohort: M1=user4,6 / M2=user4 / M3=none.

Expected Output

Expected output (ascending cohort_month, then offset_month):

cohort_monthoffset_monthcohort_sizeretainedretention_pct
2024-01-0113266.7
2024-01-0123133.3
2024-01-013300.0
2024-02-0113266.7
2024-02-0123133.3
2024-02-013300.0

Both cohorts have retained=0 → 0.0% at offset=3. CROSS JOIN keeps the zero-match rows from disappearing.

QUESTION 5
Recursive CTE (WITH RECURSIVE) — Generate a Calendar and Fill Missing DAU Dates with 0
WITH RECURSIVECOALESCEDaily DAU trendFill missing dates
Background

WITH RECURSIVE is a CTE that references itself. Its structure consists of an “anchor” (initial row) + UNION ALL + a recursive step. Always specify a termination condition (WHERE), or the recursion becomes an infinite loop.

WITH RECURSIVE date_series AS (
  SELECT '2024-01-01'::date AS dt    -- ① Anchor (starting point)
  UNION ALL
  SELECT (dt + INTERVAL '1 day')::date  -- ② Recursive step
  FROM   date_series
  WHERE  dt < '2024-01-07'              -- ③ Termination condition (required)
)
How a recursive CTE works: Starting from the anchor row dt=2024-01-01, it keeps adding the next row while WHERE dt < '2024-01-07' is true. The final addition (dt=2024-01-07) occurs from dt=2024-01-06, and recursion stops when dt=2024-01-07 < 2024-01-07 becomes false. Join the generated date column to real data with LEFT JOIN + COALESCE and fill missing dates with 0.
Problem

From the user_sessions table, output the daily DAU trend from 2024-01-01 through 2024-01-07, filling dates with no sessions with 0. Generate a date column (date_series) with WITH RECURSIVE, join the real data with LEFT JOIN, and convert NULL to 0 with COALESCE. Output columns: dt, dau (ascending dt).

Tables used
► user_sessions (8 rows)
user_idsession_date
12024-01-01
22024-01-01
12024-01-03
22024-01-03
12024-01-05
32024-01-05
12024-01-07
22024-01-07

✱ 01-02, 01-04, and 01-06 have no sessions → fill them with 0

Expected Output

Expected output (ascending dt):

dtdau
2024-01-012
2024-01-020
2024-01-032
2024-01-040
2024-01-052
2024-01-060
2024-01-072

The recursive CTE generates a seven-day calendar, and dates without real data are filled with COALESCE(NULL, 0) = 0.