Behavioral Analytics — Learn First Touch, Gap-and-Island, Recursive CTEs, and Segmentation from the Basics
BasicBehavioral analyticsWindow functionsGap-and-IslandRecursive CTE / NTILEPostgreSQL-ready5 questions
QUESTION 6
First-Touch Analysis — Identify each user's first purchase event with ROW_NUMBER()
ROW_NUMBERPARTITION BYFirst-touch analysisFirst-purchase identification
Background

Window functions use an OVER (PARTITION BY ... ORDER BY ...) clause and preserve the original rows while performing group-like calculations. ROW_NUMBER() assigns a sequence starting at 1 within each partition.

ROW_NUMBER() OVER (
  PARTITION BY user_id          -- Independent number space for each user
  ORDER BY     purchased_at      -- Assign 1, 2, 3... from oldest to newest
) AS rn
ROW_NUMBER vs RANK vs DENSE_RANK: When ties (the same timestamp) exist, ROW_NUMBER assigns distinct numbers such as 1,2,3 (controlled by additional ORDER BY columns). RANK gives tied rows the same number and skips the next number (1,1,3). DENSE_RANK does not skip it (1,1,2). ROW_NUMBER is the best choice when the first event must be narrowed to exactly one row.
Problem

From the purchase_events table, retrieve each user's first-purchase information. Return user_id, first_product_id, first_purchased_at, first_amount, ordered by user_id ascending.

Tables used
▸ purchase_events (6 rows)
user_idproduct_idpurchased_atamount
1A2024-01-053000
1B2024-01-205000
2C2024-01-082000
2A2024-02-034000
3B2024-01-151500
3A2024-01-223000

Each user has two purchase-history rows. The first row in purchased_at ascending order is the first purchase.

Expected Output

Expected output (user_id ascending):

user_idfirst_product_idfirst_purchased_atfirst_amount
1A2024-01-053000
2C2024-01-082000
3B2024-01-151500
Model Answer
WITH ranked AS (
  -- Number purchases in ascending purchase-time order for each user
  SELECT
    user_id,
    product_id,
    purchased_at,
    amount,
    ROW_NUMBER() OVER (
      PARTITION BY user_id
      ORDER BY purchased_at, product_id  -- Deterministic ordering for equal timestamps
    ) AS rn
  FROM   purchase_events
)
SELECT
  user_id,
  product_id   AS first_product_id,
  purchased_at AS first_purchased_at,
  amount       AS first_amount
FROM   ranked
WHERE  rn = 1   -- Keep only the first purchase row for each user
ORDER BY user_id;

/*
  Logical execution order:
  1. WITH ranked / CTE definition     → Define the CTE
  2. FROM purchase_events              → Read rows
  3. ROW_NUMBER() OVER (...)           → Evaluate the window function (preserve rows)
  4. FROM ranked / WHERE rn=1          → Filter rows
  5. SELECT                            → Evaluate columns
  6. ORDER BY user_id                  → Sort and output
*/
Explanation (table transitions & key points)
WITH ranked AS ( SELECT user_id, product_id, purchased_at, amount, ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY purchased_at, product_id ) AS rn FROM purchase_events ) SELECT user_id, product_id AS first_product_id, purchased_at AS first_purchased_at, amount AS first_amount FROM ranked WHERE rn = 1 ORDER BY user_id;
LEGEND
Rows read / loaded
① FROM purchase_events — Read 6 rows
FROM purchase_eventsRead all 6 rows from purchase_events. The 3 users each have two purchase-history rows. Which row is the first purchase is not known yet; the window function assigns numbers while preserving every row.
1 / 5
user_idproduct_idpurchased_atamount
1A2024-01-053000
1B2024-01-205000
2C2024-01-082000
2A2024-02-034000
3B2024-01-151500
3A2024-01-223000
purchase_events: 6 rows (3 users × 2 purchases)
LEARNING POINTS
Use ORDER BY to break ties (ensure determinism): If multiple rows have the same purchased_at, ORDER BY purchased_at alone does not define which row becomes rn=1; the choice may change between executions. In practice, add a tie-breaker such as ORDER BY purchased_at, product_id to make the result deterministic.
PARTITION BY defines the window-function group boundary: Omitting PARTITION BY makes the whole table one partition, numbering all rows 1,2,3,... . PARTITION BY user_id is what makes the sequence restart at 1 for each user.
A window-function result cannot be referenced in WHERE of the same SELECT: SELECT ..., ROW_NUMBER() AS rn FROM t WHERE rn = 1 raises an error because window functions run after WHERE. Wrap the query in a CTE or subquery, then filter with WHERE.
ANTI-PATTERNS
Try to retrieve other columns with MIN(purchased_at) + GROUP BY: SELECT user_id, product_id, MIN(purchased_at) FROM purchase_events GROUP BY user_id errors in PostgreSQL because product_id is neither grouped nor aggregated. Adding product_id to GROUP BY produces multiple rows for users with different products and cannot narrow the first purchase to one row. ROW_NUMBER is the correct pattern.
Use RANK or DENSE_RANK when only one rn=1 row is required: If multiple purchases occur on the same day, RANK assigns 1 to multiple rows. Use ROW_NUMBER and make the tie-break explicit with additional ORDER BY columns when you need exactly one first row.
FIELD NOTE: Using first-touch analysis
Aggregating the categories of first purchases reveals which categories serve as users' entry points. Comparing LTV (lifetime purchase amount) by entry category helps prioritize marketing investment. The ROW_NUMBER pattern also retrieves the second purchase with rn = 2 (a product that may trigger repeat purchase), or the last purchase with WHERE rn = MAX(rn) OVER (PARTITION BY user_id) using the same general structure.
QUESTION 7
Revisit-Interval Analysis — Calculate session gaps with LAG() and detect re-engagement
LAGOVER PARTITION BYRevisit-interval analysisRe-engagement
Background

LAG(col) is a window function that references the value from the preceding row. Separate users with PARTITION BY and sort session dates ascending with ORDER BY to obtain the previous session date.

LAG(session_date) OVER (
  PARTITION BY user_id       -- Never reference across users
  ORDER BY     session_date   -- Older sessions become preceding rows
) AS prev_session_date        -- The first row has no preceding row → NULL
date - date = INTEGER (days): In PostgreSQL, date - date returns an INTEGER number of days. session_date - prev_session_date therefore calculates the revisit interval directly in days. The difference between two TIMESTAMP values is an INTERVAL, so pay attention to the data type.
Problem

From user_sessions, calculate the previous session date and revisit interval in days for every session of every user. Return user_id, session_date, prev_session_date, days_since_last, ordered by user_id and session_date ascending.

Tables used
▸ user_sessions (8 rows)
user_idsession_date
12024-03-01
12024-03-04
12024-03-10
22024-03-02
22024-03-05
32024-03-07
32024-03-08
32024-03-20

For user3, the gap from 03-08 to 03-20 is 12 days, indicating a return after a long absence. Each user's first session has no preceding row.

Expected Output

Expected output (user_id and session_date ascending):

user_idsession_dateprev_session_datedays_since_last
12024-03-01NULLNULL
12024-03-042024-03-013
12024-03-102024-03-046
22024-03-02NULLNULL
22024-03-052024-03-023
32024-03-07NULLNULL
32024-03-082024-03-071
32024-03-202024-03-0812
Model Answer
WITH sessions_with_prev AS (
  -- Get the previous session date for each user with LAG
  SELECT
    user_id,
    session_date,
    LAG(session_date) OVER (
      PARTITION BY user_id
      ORDER BY     session_date
    ) AS prev_session_date   -- The first session has no preceding row → NULL
  FROM   user_sessions
)
SELECT
  user_id,
  session_date,
  prev_session_date,
  (session_date - prev_session_date) AS days_since_last  -- date - date = INTEGER (days)
FROM   sessions_with_prev
ORDER BY user_id, session_date;

/*
  Logical execution order:
  1. WITH sessions_with_prev / CTE definition → Define the CTE
  2. FROM user_sessions                     → Read rows
  3. LAG(session_date) OVER (...)           → Evaluate the window function (preserve rows)
  4. FROM sessions_with_prev                → Read rows
  5. SELECT                                 → Evaluate columns (days_since_last)
  6. ORDER BY user_id, session_date         → Sort and output
*/
Explanation (table transitions & key points)
WITH sessions_with_prev AS ( SELECT user_id, session_date, LAG(session_date) OVER ( PARTITION BY user_id ORDER BY session_date ) AS prev_session_date FROM user_sessions ) SELECT user_id, session_date, prev_session_date, (session_date - prev_session_date) AS days_since_last FROM sessions_with_prev ORDER BY user_id, session_date;
LEGEND
Rows read / loaded
① FROM user_sessions — Read 8 rows
FROM user_sessionsRead all 8 rows from user_sessions. The 3 users have session histories. Set user boundaries with PARTITION BY, sort by session order, and then use LAG to reference the preceding row.
1 / 4
user_idsession_date
12024-03-01
12024-03-04
12024-03-10
22024-03-02
22024-03-05
32024-03-07
32024-03-08
32024-03-20
user_sessions: 8 rows (user1: 3 / user2: 2 / user3: 3)
LEARNING POINTS
LAG vs LEAD — choose the direction: LAG(col) references the preceding row (the past), while LEAD(col) references the following row (the future). Use LAG for revisit intervals. LEAD calculates days until the next session; rows with LEAD=NULL can be candidates for churn prediction.
Without PARTITION BY, references cross user boundaries: If PARTITION BY user_id is omitted, user2's first session may reference user1's last session as the previous row. This is a critical bug that calculates a date difference across different users, so PARTITION BY is essential.
Control offset and default values with LAG's second and third arguments: LAG(session_date, 2) reads two rows back, while LAG(session_date, 1, '2000-01-01') supplies a default when there is no preceding row. Use COALESCE or the third argument to replace first-session NULLs with a value such as 0.
ANTI-PATTERNS
Use LAG without PARTITION BY: The preceding row can belong to another user, so user2's first session receives user1's last session date as prev_session_date. days_since_last becomes meaningless and the analysis is corrupted.
Expect INTEGER from a TIMESTAMP difference: If session_date is TIMESTAMP, session_date - prev_session_date returns INTERVAL (for example, '3 days'). To get an INTEGER number of days, use EXTRACT(DAY FROM (session_date - prev_session_date)) or an ::int cast.
FIELD NOTE: Applying re-engagement campaigns
Users whose sessions have a large days_since_last value are spontaneous returners after a long absence. This segment often responds well to push notifications and email re-engagement campaigns. Use WHERE days_since_last >= 14 to find sessions after a gap of at least 14 days, then analyze the following behavior (purchases and retention) to measure the effectiveness of post-return programs.
QUESTION 8
Consecutive-Active-Days Analysis — Detect login streaks with GAP-AND-ISLAND
GAP-AND-ISLANDROW_NUMBERStreak detectionConsecutive behavior analysis
Background

GAP-AND-ISLAND detects consecutive rows (islands) and gaps. Subtracting the row-number offset within a partition from each row's date produces the same value for consecutive dates, which works as a group key.

-- Consecutive dates produce the same grp
login_date   rn  login_date - (rn-1)  grp
2024-01-01   1   01-01 - 0 days  =  2024-01-01  ← same island
2024-01-02   2   01-02 - 1 day   =  2024-01-01  ← same island
2024-01-03   3   01-03 - 2 days  =  2024-01-01  ← same island
2024-01-05   4   01-05 - 3 days  =  2024-01-02  ← new island (January 4 is missing)
date - integer = date (PostgreSQL): In PostgreSQL, date - integer returns a date. login_date - (rn - 1) therefore produces a date-type group key directly; no INTERVAL cast is needed.
Problem

From login_logs, calculate the start date, end date, and number of consecutive days for each user's login streak. Return user_id, streak_start, streak_end, streak_days, ordered by user_id and streak_start ascending.

Tables used
▸ login_logs (8 rows)
user_idlogin_date
12024-01-01
12024-01-02
12024-01-03
12024-01-05
22024-01-03
22024-01-04
22024-01-05
22024-01-06

User1: 01-01–03 is consecutive (3 days), while 01-05 is alone (1 day). User2: 01-03–06 is consecutive (4 days).

Expected Output

Expected output (user_id and streak_start ascending):

user_idstreak_startstreak_endstreak_days
12024-01-012024-01-033
12024-01-052024-01-051
22024-01-032024-01-064
Model Answer
WITH numbered AS (
  -- Number login dates in ascending order for each user
  SELECT
    user_id,
    login_date,
    ROW_NUMBER() OVER (
      PARTITION BY user_id
      ORDER BY     login_date
    ) AS rn
  FROM   login_logs
),
grouped AS (
  -- Subtract the row-number offset → consecutive dates share grp
  SELECT
    user_id,
    login_date,
    login_date - (rn - 1)  AS grp  -- date - integer = date (island key)
  FROM   numbered
)
SELECT
  user_id,
  MIN(login_date) AS streak_start,
  MAX(login_date) AS streak_end,
  COUNT(*)         AS streak_days
FROM   grouped
GROUP BY user_id, grp
ORDER BY user_id, streak_start;

/*
  Logical execution order:
  1. WITH numbered / CTE definition        → Define the CTE
  2. FROM login_logs                        → Read rows
  3. ROW_NUMBER() OVER (...)               → Evaluate the window function (preserve rows)
  4. WITH grouped / CTE definition         → Define the CTE (calculate grp)
  5. FROM grouped                           → Read rows
  6. GROUP BY user_id, grp                  → Form groups
  7. SELECT                                 → Evaluate aggregates (MIN, MAX, COUNT)
  8. ORDER BY user_id, streak_start        → Sort and output
*/
Explanation (table transitions & key points)
WITH numbered AS ( SELECT user_id, login_date, ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY login_date ) AS rn FROM login_logs ), grouped AS ( SELECT user_id, login_date, login_date - (rn - 1) AS grp FROM numbered ) SELECT user_id, MIN(login_date) AS streak_start, MAX(login_date) AS streak_end, COUNT(*) AS streak_days FROM grouped GROUP BY user_id, grp ORDER BY user_id, streak_start;
LEGEND
Rows read / loaded
① FROM login_logs — Read 8 rows + assign ROW_NUMBER
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) AS rnRead 8 rows from login_logs and assign a row number by login date ascending for each user. This is the first stage of the GAP-AND-ISLAND technique.
1 / 5
user_idlogin_date▸ rn
12024-01-011
12024-01-022
12024-01-033
12024-01-054
22024-01-031
22024-01-042
22024-01-053
22024-01-064
numbered CTE: 8 rows
LEARNING POINTS
The core of GAP-AND-ISLAND: date - sequence number = constant for consecutive rows: Subtracting a ROW_NUMBER offset from a sequence of dates makes consecutive rows share the same value. That value is the island group key; a one-day gap changes grp and marks the start of a new island. The same idea applies to any consecutive integer sequence, not only dates.
Extend it to max_streak: To find each user's longest streak, wrap the result again with SELECT user_id, MAX(streak_days) FROM ... GROUP BY user_id. This directly supports gamification features such as daily login bonuses and continuing-days rankings in health apps.
Change the definition of consecutive: For a weekly streak instead of daily login, first convert dates to weeks with DATE_TRUNC('week', login_date), then apply the same pattern. This technique generalizes to consecutive periods of any chosen unit.
ANTI-PATTERNS
Only compare the previous day with LAG + CASE: Setting a flag with CASE WHEN login_date - LAG(login_date) = 1 THEN 1 ELSE 0 END does not aggregate island start, end, and length in one query. Another grouping query is needed after flagging, which is verbose. GAP-AND-ISLAND completes the transformation in one CTE chain.
Ignore duplicate rows in login_logs: If the same user has multiple rows on one date, rn becomes too large and grp shifts, incorrectly splitting islands. Remove duplicates first with SELECT DISTINCT user_id, login_date FROM login_logs, then apply GAP-AND-ISLAND.
FIELD NOTE: Where streak analysis is useful
Consecutive login days are one of the most intuitive indicators of user engagement. Duolingo and habit-tracking apps commonly award a badge for “7 consecutive login days.” Use WHERE streak_days >= 7 to find users who achieved a streak, then compare their subsequent retention with users who did not achieve one to quantify the streak feature's contribution to LTV.
QUESTION 9
Date-Sequence Generation — Calculate zero-filled daily counts and cumulative registrations with WITH RECURSIVE
WITH RECURSIVESUM OVERDate-sequence generationCumulative totals / zero filling
Background

WITH RECURSIVE is a self-referencing CTE. The anchor member (non-recursive term) returns the first row, and the recursive member references prior results to add rows repeatedly. UNION ALL repeats this process until the WHERE stopping condition is reached.

WITH RECURSIVE date_series AS (
  -- Anchor member: the first row
  SELECT '2024-01-01'::date AS dt
  UNION ALL
  -- Recursive member: advance one day from the prior result
  SELECT (dt + INTERVAL '1 day')::date
  FROM   date_series
  WHERE  dt < '2024-01-05'::date   -- Required stopping condition
)
A stopping condition is required: Omitting WHERE can create an infinite recursion and exhaust database resources. Always write an explicit stopping condition for a recursive CTE.
Problem

From users, calculate the daily new-user count and cumulative registered-user count from 2024-01-01 through 2024-01-05. Display days with no registrations as 0 (zero filling). Return date, new_users, cumulative_users, ordered by date ascending.

Tables used
▸ users (5 rows)
user_idregistered_at
12024-01-01
22024-01-01
32024-01-03
42024-01-05
52024-01-05

01-02 and 01-04 have no registrations. WITH RECURSIVE generates the date sequence and a LEFT JOIN fills the missing dates with zero.

Expected Output

Expected output (date ascending):

datenew_userscumulative_users
2024-01-0122
2024-01-0202
2024-01-0313
2024-01-0403
2024-01-0525
Model Answer
WITH RECURSIVE date_series AS (
  -- Anchor member: generate one row for the aggregation start date
  SELECT '2024-01-01'::date AS dt
  UNION ALL
  -- Recursive member: advance one day until the stopping condition
  SELECT (dt + INTERVAL '1 day')::date
  FROM   date_series
  WHERE  dt < '2024-01-05'::date   -- Required stopping condition
),
daily_new AS (
  -- Count new registrations by day (no row for a day with no registrations)
  SELECT
    registered_at::date  AS dt,
    COUNT(*)              AS new_users
  FROM   users
  GROUP BY registered_at::date
)
SELECT
  ds.dt                         AS date,
  COALESCE(dn.new_users, 0)     AS new_users,        -- Replace NULL with 0 for zero filling
  SUM(COALESCE(dn.new_users, 0)) OVER (
    ORDER BY ds.dt
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
  )                             AS cumulative_users  -- Running sum through the current row
FROM   date_series ds
LEFT JOIN daily_new dn ON dn.dt = ds.dt
ORDER BY ds.dt;

/*
  Logical execution order:
  1. CTE date_series (recursive):
     Evaluate the anchor (start point)
     Expand dates one day at a time recursively
     Stop when no additional row is produced
  2. CTE daily_new:
     FROM users → GROUP BY registered_at::date → group and count
  3. FROM date_series ds            → Read rows
     LEFT JOIN daily_new dn         → Join while retaining every left-side row
  4. SUM(...) OVER (...)            → Evaluate the window function (preserve rows)
  5. ORDER BY ds.dt                 → Sort and output
*/
Explanation (table transitions & key points)
WITH RECURSIVE date_series AS ( SELECT '2024-01-01'::date AS dt UNION ALL SELECT (dt + INTERVAL '1 day')::date FROM date_series WHERE dt < '2024-01-05'::date ), daily_new AS ( SELECT registered_at::date AS dt, COUNT(*) AS new_users FROM users GROUP BY registered_at::date ) SELECT ds.dt AS date, COALESCE(dn.new_users, 0) AS new_users, SUM(COALESCE(dn.new_users, 0)) OVER ( ORDER BY ds.dt ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS cumulative_users FROM date_series ds LEFT JOIN daily_new dn ON dn.dt = ds.dt ORDER BY ds.dt;
LEGEND
Rows read / loaded
✓ pass
① Anchor member — SELECT '2024-01-01' creates the first row
SELECT '2024-01-01'::date AS dt (non-recursive term)The non-recursive anchor member of WITH RECURSIVE creates the first row. This row is the starting point of the recursion and the anchor runs only once.
1 / 9
dtRole
2024-01-01✓ Anchor (start / non-recursive term)
date_series initial state: 1 row
LEARNING POINTS
The three elements of WITH RECURSIVE: anchor, UNION ALL, and stopping condition: The anchor generates the initial value, UNION ALL connects it to the recursive member without deduplicating, and the recursive member's WHERE clause stops the process. UNION would spend extra work removing duplicates, so recursive CTEs should use UNION ALL.
LEFT JOIN zero filling and COALESCE NULL conversion: LEFT JOIN daily_new to date_series, which contains every date including dates without registrations. Missing dates produce dn.new_users = NULL; COALESCE(dn.new_users, 0) converts that NULL to 0, the standard zero-filling pattern.
The ROWS BETWEEN clause of SUM() OVER: ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW defines a window from the first row through the current row. This is the standard running-total pattern. PostgreSQL gives the same result with ORDER BY alone, but the explicit frame makes the intent clear.
ANTI-PATTERNS
Omit the recursive stopping condition: Without WHERE, the recursive member keeps running indefinitely and can exhaust server resources. Always write an explicit termination condition.
Use GENERATE_SERIES when it is available: PostgreSQL can generate a date sequence with GENERATE_SERIES('2024-01-01'::date, '2024-01-05'::date, '1 day'::interval), which is shorter than WITH RECURSIVE. However, databases such as BigQuery and Snowflake may require WITH RECURSIVE when GENERATE_SERIES is unavailable.
FIELD NOTE: Cumulative metrics and growth visualization
A cumulative-user graph shows a service's growth curve. A steeper slope means faster growth; a flattening slope signals slowing growth. Computing daily, weekly, or monthly cumulative values in SQL and displaying them on a dashboard is a standard BizOps and data-analyst task. Zero filling with WITH RECURSIVE is foundational for drawing a graph that correctly shows 0 even on days with no sales.
QUESTION 10
User Segmentation — Classify purchase amounts into quartiles with NTILE() and learn the basics of RFM analysis
NTILECASE WHENUser segmentationRFM analysis / quartile classification
Background

NTILE(n) is a window function that divides rows into n buckets and assigns each row a bucket number from 1 to n. It is useful for classifying users into quartiles based on purchase amount.

NTILE(4) OVER (
  ORDER BY total_amount ASC   -- Low amounts are quartile=1; high amounts are quartile=4
) AS quartile
quartileDescriptionRole in RFM
1Users in the lowest 25% by purchase amountLow-value segment (nurture)
2Lower 25–50%Mid-value segment (retain)
3Upper 25–50%High-value segment (retain / reward)
4Users in the highest 25% by purchase amountBest segment (VIP programs)
Handling remainders: When the row count is not divisible by n, extra rows are assigned one at a time starting with the first bucket. For example, 9 rows split with NTILE(4) produce 3,2,2,2 rows per bucket (distributed in order from 1 to 4).
Problem

From orders, calculate each user's total purchase amount, classify users into quartiles with NTILE(4), and attach a segment label. Return user_id, total_amount, quartile, segment (Bronze through Platinum), ordered by total_amount ascending.

Tables used
▸ orders (10 rows)
user_idorder_dateamount
12024-01-05500
22024-01-081500
32024-01-102000
42024-01-121200
42024-02-011800
52024-01-184000
62024-01-226000
72024-02-054000
72024-02-154000
82024-01-2512000

User4: 1200+1800=3000. User7: 4000+4000=8000. The 8 users are assigned two per NTILE(4) quartile.

Expected Output

Expected output (total_amount ascending):

user_idtotal_amountquartilesegment
15001Bronze
215001Bronze
320002Silver
430002Silver
540003Gold
660003Gold
780004Platinum
8120004Platinum
Model Answer
WITH user_totals AS (
  -- Aggregate total purchase amount for each user
  SELECT
    user_id,
    SUM(amount) AS total_amount
  FROM   orders
  GROUP BY user_id
),
segmented AS (
  -- Sort by total amount ascending and split into four quartiles
  SELECT
    user_id,
    total_amount,
    NTILE(4) OVER (
      ORDER BY total_amount ASC  -- Assign quartile=1 from the lowest amounts
    ) AS quartile
  FROM   user_totals
)
SELECT
  user_id,
  total_amount,
  quartile,
  CASE quartile
    WHEN 1 THEN 'Bronze'
    WHEN 2 THEN 'Silver'
    WHEN 3 THEN 'Gold'
    WHEN 4 THEN 'Platinum'
  END AS segment
FROM   segmented
ORDER BY total_amount;

/*
  Logical execution order:
  1. WITH user_totals / CTE definition → Define the CTE
  2. FROM orders                      → Read rows
  3. GROUP BY user_id                 → Form groups
  4. SELECT                           → Evaluate the aggregate (SUM)
  5. WITH segmented / CTE definition  → Define the CTE
  6. NTILE(4) OVER (...)              → Evaluate the window function (preserve rows)
  7. SELECT                           → Evaluate columns (assign CASE labels)
  8. ORDER BY total_amount            → Sort and output
*/
Explanation (table transitions & key points)
WITH user_totals AS ( SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id ), segmented AS ( SELECT user_id, total_amount, NTILE(4) OVER ( ORDER BY total_amount ASC ) AS quartile FROM user_totals ) SELECT user_id, total_amount, quartile, CASE quartile WHEN 1 THEN 'Bronze' WHEN 2 THEN 'Silver' WHEN 3 THEN 'Gold' WHEN 4 THEN 'Platinum' END AS segment FROM segmented ORDER BY total_amount;
LEGEND
Rows read / loaded
① FROM orders + GROUP BY user_id — Aggregate total purchase amount
GROUP BY user_id → SUM(amount) AS total_amountRead the 10 orders and aggregate SUM(amount) by user. User4 (1200+1800=3000) and user7 (4000+4000=8000) have multiple orders, which are added together.
1 / 5
user_idOrder rows▸ total_amount
11 order500
21 order1500
31 order2000
42 orders (1200+1800)3000
51 order4000
61 order6000
72 orders (4000+4000)8000
81 order12000
user_totals CTE: 8 rows (one total per user)
LEARNING POINTS
How NTILE(n) works: n buckets, with remainders assigned from the first bucket: If the row count is not divisible by n, extra rows are added one by one starting at quartile=1. NTILE(4) over 9 rows produces 3,2,2,2 rows per bucket, with the first bucket receiving one extra row. It divides row counts, not value ranges, which is the key difference from PERCENTILE.
Pay attention to ASC vs DESC: ORDER BY total_amount ASC makes low-value users quartile=1 (Bronze). DESC makes high-value users quartile=1 and reverses the meaning. Choose ASC when higher quartiles should represent better customers, and DESC when 1 should be the best score.
Extend to the three RFM axes: RFM scores each user on Recency (last purchase date), Frequency (purchase count), and Monetary (purchase amount). This quiz covers Monetary only; a full RFM analysis applies NTILE(5) to each axis for scores from 1 to 5 and defines segments from the score combination.
ANTI-PATTERNS
Use NTILE without ORDER BY: Omitting ORDER BY, as in NTILE(4) OVER (), assigns buckets from physical row order. Insert order is not guaranteed, so results can change between executions. Always provide a meaningful ORDER BY to NTILE.
Confuse NTILE with PERCENT_RANK: PERCENT_RANK() returns a relative rank from 0 to 1 (0.0 for the minimum and 1.0 for the maximum). Use NTILE for a fixed number of buckets; use PERCENT_RANK or CUME_DIST to find users in the top X%.
FIELD NOTE: Improve CRM precision with RFM analysis
RFM analysis greatly improves targeting for email marketing and push notifications. Send VIP offers to Platinum users (high amount, high frequency, recent purchase) and first-purchase coupons to Bronze users (low amount, low frequency). Changing communication by segment improves CVR. In SQL, assign a Recency score with NTILE(5) OVER (ORDER BY last_purchase_date DESC) and a Frequency score with NTILE(5) OVER (ORDER BY purchase_count), then JOIN three CTEs to combine the RFM scores for each user.