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 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.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.
| user_id | product_id | purchased_at | amount |
|---|---|---|---|
| 1 | A | 2024-01-05 | 3000 |
| 1 | B | 2024-01-20 | 5000 |
| 2 | C | 2024-01-08 | 2000 |
| 2 | A | 2024-02-03 | 4000 |
| 3 | B | 2024-01-15 | 1500 |
| 3 | A | 2024-01-22 | 3000 |
Each user has two purchase-history rows. The first row in purchased_at ascending order is the first purchase.
Expected output (user_id ascending):
| user_id | first_product_id | first_purchased_at | first_amount |
|---|---|---|---|
| 1 | A | 2024-01-05 | 3000 |
| 2 | C | 2024-01-08 | 2000 |
| 3 | B | 2024-01-15 | 1500 |
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 */
LEGEND
① 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.| user_id | product_id | purchased_at | amount |
|---|---|---|---|
| 1 | A | 2024-01-05 | 3000 |
| 1 | B | 2024-01-20 | 5000 |
| 2 | C | 2024-01-08 | 2000 |
| 2 | A | 2024-02-03 | 4000 |
| 3 | B | 2024-01-15 | 1500 |
| 3 | A | 2024-01-22 | 3000 |
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 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.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.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.ROW_NUMBER and make the tie-break explicit with additional ORDER BY columns when you need exactly one first row.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.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 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.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.
| user_id | session_date |
|---|---|
| 1 | 2024-03-01 |
| 1 | 2024-03-04 |
| 1 | 2024-03-10 |
| 2 | 2024-03-02 |
| 2 | 2024-03-05 |
| 3 | 2024-03-07 |
| 3 | 2024-03-08 |
| 3 | 2024-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 (user_id and session_date ascending):
| user_id | session_date | prev_session_date | days_since_last |
|---|---|---|---|
| 1 | 2024-03-01 | NULL | NULL |
| 1 | 2024-03-04 | 2024-03-01 | 3 |
| 1 | 2024-03-10 | 2024-03-04 | 6 |
| 2 | 2024-03-02 | NULL | NULL |
| 2 | 2024-03-05 | 2024-03-02 | 3 |
| 3 | 2024-03-07 | NULL | NULL |
| 3 | 2024-03-08 | 2024-03-07 | 1 |
| 3 | 2024-03-20 | 2024-03-08 | 12 |
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 */
LEGEND
① 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.| user_id | session_date |
|---|---|
| 1 | 2024-03-01 |
| 1 | 2024-03-04 |
| 1 | 2024-03-10 |
| 2 | 2024-03-02 |
| 2 | 2024-03-05 |
| 3 | 2024-03-07 |
| 3 | 2024-03-08 |
| 3 | 2024-03-20 |
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.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.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.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.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.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 returns a date. login_date - (rn - 1) therefore produces a date-type group key directly; no INTERVAL cast is needed.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.
| user_id | login_date |
|---|---|
| 1 | 2024-01-01 |
| 1 | 2024-01-02 |
| 1 | 2024-01-03 |
| 1 | 2024-01-05 |
| 2 | 2024-01-03 |
| 2 | 2024-01-04 |
| 2 | 2024-01-05 |
| 2 | 2024-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 (user_id and streak_start ascending):
| user_id | streak_start | streak_end | streak_days |
|---|---|---|---|
| 1 | 2024-01-01 | 2024-01-03 | 3 |
| 1 | 2024-01-05 | 2024-01-05 | 1 |
| 2 | 2024-01-03 | 2024-01-06 | 4 |
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 */
LEGEND
① 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.| user_id | login_date | ▸ rn |
|---|---|---|
| 1 | 2024-01-01 | 1 |
| 1 | 2024-01-02 | 2 |
| 1 | 2024-01-03 | 3 |
| 1 | 2024-01-05 | 4 |
| 2 | 2024-01-03 | 1 |
| 2 | 2024-01-04 | 2 |
| 2 | 2024-01-05 | 3 |
| 2 | 2024-01-06 | 4 |
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.DATE_TRUNC('week', login_date), then apply the same pattern. This technique generalizes to consecutive periods of any chosen unit.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.SELECT DISTINCT user_id, login_date FROM login_logs, then apply GAP-AND-ISLAND.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.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 )
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.
| user_id | registered_at |
|---|---|
| 1 | 2024-01-01 |
| 2 | 2024-01-01 |
| 3 | 2024-01-03 |
| 4 | 2024-01-05 |
| 5 | 2024-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 (date ascending):
| date | new_users | cumulative_users |
|---|---|---|
| 2024-01-01 | 2 | 2 |
| 2024-01-02 | 0 | 2 |
| 2024-01-03 | 1 | 3 |
| 2024-01-04 | 0 | 3 |
| 2024-01-05 | 2 | 5 |
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 */
LEGEND
① 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.| dt | Role |
|---|---|
| 2024-01-01 | ✓ Anchor (start / non-recursive term) |
COALESCE(dn.new_users, 0) converts that NULL to 0, the standard zero-filling pattern.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.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.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
| quartile | Description | Role in RFM |
|---|---|---|
| 1 | Users in the lowest 25% by purchase amount | Low-value segment (nurture) |
| 2 | Lower 25–50% | Mid-value segment (retain) |
| 3 | Upper 25–50% | High-value segment (retain / reward) |
| 4 | Users in the highest 25% by purchase amount | Best segment (VIP programs) |
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.
| user_id | order_date | amount |
|---|---|---|
| 1 | 2024-01-05 | 500 |
| 2 | 2024-01-08 | 1500 |
| 3 | 2024-01-10 | 2000 |
| 4 | 2024-01-12 | 1200 |
| 4 | 2024-02-01 | 1800 |
| 5 | 2024-01-18 | 4000 |
| 6 | 2024-01-22 | 6000 |
| 7 | 2024-02-05 | 4000 |
| 7 | 2024-02-15 | 4000 |
| 8 | 2024-01-25 | 12000 |
User4: 1200+1800=3000. User7: 4000+4000=8000. The 8 users are assigned two per NTILE(4) quartile.
Expected output (total_amount ascending):
| user_id | total_amount | quartile | segment |
|---|---|---|---|
| 1 | 500 | 1 | Bronze |
| 2 | 1500 | 1 | Bronze |
| 3 | 2000 | 2 | Silver |
| 4 | 3000 | 2 | Silver |
| 5 | 4000 | 3 | Gold |
| 6 | 6000 | 3 | Gold |
| 7 | 8000 | 4 | Platinum |
| 8 | 12000 | 4 | Platinum |
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 */
LEGEND
① 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.| user_id | Order rows | ▸ total_amount |
|---|---|---|
| 1 | 1 order | 500 |
| 2 | 1 order | 1500 |
| 3 | 1 order | 2000 |
| 4 | 2 orders (1200+1800) | 3000 |
| 5 | 1 order | 4000 |
| 6 | 1 order | 6000 |
| 7 | 2 orders (4000+4000) | 8000 |
| 8 | 1 order | 12000 |
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.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.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%.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.