Behavioral Analytics — Learn Attribution, Sessionization, RFM, and Hierarchical SQL in Practice
AdvancedBehavioral analyticsFIRST / LAST_VALUESessionizationHierarchical CTETwo-axis RFMPostgreSQL-compatible5 questions
QUESTION 6
Attribution Analysis — Retrieve First- and Last-Touch Channels with FIRST_VALUE / LAST_VALUE
FIRST_VALUELAST_VALUEROWS BETWEENAttributionMulti-touch
Background

FIRST_VALUE / LAST_VALUE are window functions that return the first and last values within a partition. Be careful: unless you explicitly specify the window frame (ROWS BETWEEN …), they may return unintended values.

FIRST_VALUE(channel) OVER (
  PARTITION BY user_id
  ORDER BY     touched_at
  ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING  -- Use the whole partition as the frame
) AS first_channel
LAST_VALUE pitfall: The default frame for a window function with ORDER BY is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. With this default, LAST_VALUE returns the current row's value, not the value from the final row. Always specify ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
Marketing attribution: The first-touch channel represents the contribution to awareness, while the last-touch channel represents the push immediately before conversion. Whether they match distinguishes “quick-decision users” from “comparison-oriented users.”
Problem

From the touch_events table, retrieve each user's first-touch channel, last-touch channel, total touch count, and whether the two channels match. Return user_id, first_channel, last_channel, total_touches, is_same_channel in ascending user_id order.

Tables used
▸ touch_events (8 rows)
user_idchanneltouched_at
1google2024-01-05
1email2024-01-10
1direct2024-01-15
2facebook2024-02-01
2facebook2024-02-08
3google2024-02-10
3email2024-02-12
3google2024-02-20

✱ user1: google → email → direct (3 channels, no match); user2: facebook only (quick decision); user3: google → email → google (returning pattern).

Expected Output

Expected output (ascending user_id):

user_idfirst_channellast_channeltotal_touchesis_same_channel
1googledirect3FALSE
2facebookfacebook2TRUE
3googlegoogle3TRUE
QUESTION 7
Sessionization — Assign Session IDs at 30-Minute Gaps with LAG + Cumulative SUM OVER
LAGSUM OVERCASE WHENSessionizationSession splitting
Background

Sessionization is one of the most important patterns in web analytics. Accesses separated by more than a certain interval (typically 30 minutes) are split into a new session. The implementation measures the gap from the previous row with LAG, flags gaps over the threshold, and assigns session IDs with the cumulative sum.

-- Core sessionization logic: turn flags into sequence numbers with a cumulative SUM
new_session_flag        cumulative SUM (= session_id)
1   ← first row              →  1
0   ← within 30 minutes      →  1
0                            →  1
1   ← over 30 min = new session →  2
0                            →  2
1                            →  3
EXTRACT(EPOCH FROM …) / 60: The difference between TIMESTAMP values has the INTERVAL type. The standard approach is to convert it to EPOCH (seconds) and divide by 60 to work in minutes. EXTRACT(EPOCH FROM (t2 - t1)) / 60 returns the number of minutes from t1 to t2.
Problem

From the page_views table, assign a new session ID when more than 30 minutes have elapsed between page views for each user. Return user_id, viewed_at, gap_minutes, session_id in ascending user_id and viewed_at order.

Tables used
▸ page_views (8 rows)
user_idviewed_at
12024-01-05 10:00
12024-01-05 10:15
12024-01-05 10:25
12024-01-05 11:30
12024-01-05 11:45
22024-01-05 14:00
22024-01-05 15:00
22024-01-05 15:10

✱ user1: 10:25→11:30 is a 65-minute gap (new session); user2: 14:00→15:00 is a 60-minute gap (new session). Gaps of 30 minutes or less stay in the same session.

Expected Output

Expected output (ascending user_id and viewed_at):

user_idviewed_atgap_minutessession_id
12024-01-05 10:00NULL1
12024-01-05 10:15151
12024-01-05 10:25101
12024-01-05 11:30652
12024-01-05 11:45152
22024-01-05 14:00NULL1
22024-01-05 15:00602
22024-01-05 15:10102
QUESTION 8
Longest Consecutive-Purchase Streak — Calculate a Production-Grade KPI with GAP-AND-ISLAND, Ranking, and an Active-Status Check
GAP-AND-ISLANDROW_NUMBERMAX OVERLongest streakActive flag
Background

Extend the GAP-AND-ISLAND pattern from the basic section to calculate each user's longest consecutive-purchase period and whether that streak is still active in one query. After detecting islands (consecutive periods) with GAP-AND-ISLAND, rank the longest island per user and check whether its end date matches the latest purchase date.

-- A design implemented with four CTE stages
1) numbered  : Assign sequence numbers with ROW_NUMBER
2) grouped   : Generate an island key with date - (rn-1)
3) streaks   : Aggregate each island with MIN/MAX/COUNT
4) ranked    : Rank the longest island with ROW_NUMBER and get the latest date with MAX(end) OVER
5) Final SELECT: Keep rnk=1 and determine is_active
Tie-break for equal-length streaks: If multiple streaks have the same maximum length, prefer the one with the newer streak_end (ORDER BY streak_days DESC, streak_end DESC). In practice, the most recent longest record is more useful for deciding an action.
Problem

From the daily_orders table, calculate each user's longest consecutive-purchase streak (start date, end date, and length) and whether that streak is still active. “Active” means that the longest streak's end date equals the user's latest purchase date. Return user_id, max_streak_days, streak_start, streak_end, is_active in ascending user_id order.

Tables used
▸ daily_orders (13 rows)
user_idorder_date
12024-01-01
12024-01-02
12024-01-03
12024-01-05
12024-01-06
22024-01-10
22024-01-11
22024-01-12
22024-01-13
22024-01-14
32024-01-20
32024-01-21
32024-01-25

✱ user1: 01-01–03 (3 days) and 01-05–06 (2 days). The longest is 3 days, but the latest date is 01-06 → not active. user2: 01-10–14 is a 5-day streak (matches the latest date → active). user3: 01-20–21 (2 days) and a single order on 01-25 → the longest is 2 days, but the latest date is 01-25 → not active.

Expected Output

Expected output (ascending user_id):

user_idmax_streak_daysstreak_startstreak_endis_active
132024-01-012024-01-03FALSE
252024-01-102024-01-14TRUE
322024-01-202024-01-21FALSE
QUESTION 9
Invite-Tree Hierarchy Analysis — Traverse Multi-Level Invites with WITH RECURSIVE and Aggregate Depth and Counts
WITH RECURSIVEHierarchical querySelf-referencing JOINInvite treeHierarchy depth
Background

Basic Q9 used WITH RECURSIVE to generate a date sequence. Here we tackle its original purpose: exploring a hierarchical structure (tree). It is a standard technique for traversing self-referencing tables such as invitation relationships, organization charts, comment threads, and file systems.

-- Hierarchical recursion (traverse the invite tree from the root)
WITH RECURSIVE tree AS (
  -- Anchor: start with the root (a user with no inviter)
  SELECT user_id, 0 AS depth
  FROM   users
  WHERE  invited_by IS NULL
  UNION ALL
  -- Recursive member: self-reference JOIN to the previous result, depth +1
  SELECT u.user_id, t.depth + 1
  FROM   tree t
  JOIN   users u ON u.invited_by = t.user_id
)
Cumulative result vs. rows added this time: In a recursive CTE, writing FROM tree references only the rows added in the previous recursive iteration, not the entire cumulative result. This makes the JOIN generate only the next hierarchy level.
Cycle detection: A cycle such as A→B→A in an invite tree causes an infinite loop. This question assumes acyclic data, but in production it is safer to detect cycles with WHERE NOT visited or path-array tracking.
Problem

From the users table (self-referencing: invited_by identifies the user who invited that user), retrieve each user's invite depth (depth: levels below the root) and the number of users they directly invited. Return user_id, name, depth, direct_invitees in ascending depth and user_id order.

Tables used
▸ users (7 rows, self-referencing)
user_idnameinvited_by
1AliceNULL
2Bob1
3Carol1
4Dave2
5Eve2
6Frank4
7Grace3

✱ Root: Alice (invited_by=NULL). Hierarchy: Alice(0) → Bob,Carol(1) → Dave,Eve,Grace(2) → Frank(3).

Expected Output

Expected output (ascending depth and user_id):

user_idnamedepthdirect_invitees
1Alice02
2Bob12
3Carol11
4Dave21
5Eve20
7Grace20
6Frank30
QUESTION 10
Two-Axis RFM Matrix — Build Practical Segments with a 3×3 Recency × Monetary Grid
NTILE(3)RFMCASE WHENTwo-axis segmentationBusiness labels
Background

Basic Q10 introduced one-axis Monetary segmentation with NTILE(4). Here we tackle the R × M (Recency × Monetary) two-axis matrix used in real business settings. Apply NTILE(3) independently to each axis and assign a business label to each of the 3×3=9 cells.

R↓ M→
M=1 (low)
M=2 (medium)
M=3 (high)
R=3 (recent)
New / nurture
Prospective VIP
VIP
R=2 (middle)
General
Standard
Churn watch
R=1 (past)
Dormant
Dormant
Lost-customer risk
Direction for calculating Recency with NTILE: To make “recently purchased users = R=3 (high score),” apply NTILE in descending order with ORDER BY last_purchase_date DESC, then reverse the resulting rank with 4 - r_rank. Ascending dates make older users score 3, reversing the meaning.
Problem

From the customer_purchases table, apply NTILE(3) to each user's latest purchase date (Recency) and total purchase amount (Monetary), classify users into a 3×3=9-cell matrix, and assign segment labels. Return user_id, last_purchase_date, total_amount, r_score, m_score, segment in descending r_score and m_score order.

Tables used
▸ customer_purchases (12 rows)
user_idpurchase_dateamount
12024-03-2515000
22024-03-208000
32024-03-282000
32024-03-302500
42024-02-1512000
52024-02-105000
52024-02-252000
62024-02-203000
72024-01-0520000
72024-01-2010000
82024-01-105000
92024-01-151500

✱ 9 users. After aggregation: user1=15000, user3=4500 (2 purchases), user5=7000 (2 purchases), user7=30000 (2 purchases), etc. With 9÷3=3, the design distributes three users cleanly into each bucket.

Expected Output

Expected output (descending r_score and m_score):

user_idlast_purchase_datetotal_amountr_scorem_scoresegment
12024-03-251500033VIP
22024-03-20800032Prospective VIP
32024-03-30450031New / nurture
42024-02-151200023Churn watch
52024-02-25700022Standard
62024-02-20300021General
72024-01-203000013Lost-customer risk
82024-01-10500012Dormant
92024-01-15150011Dormant