In event modeling, actions such as clicks, purchases, and views are recorded in one table in a vertical (tall) format. During analysis, it is often necessary to convert this into a horizontal (wide) format. PostgreSQL's FILTER (WHERE ...) clause lets you aggregate each event type into one row.
-- Vertical format (the standard event-table shape) user_id | event_type | event_time 1 | view | 2024-01-10 10:00 1 | click | 2024-01-10 10:05 1 | purchase | 2024-01-10 10:30 -- Desired horizontal-format result user_id | view_cnt | click_cnt | purchase_cnt 1 | 1 | 1 | 1
COUNT(*) FILTER (WHERE event_type = 'view') counts only the rows in that group that satisfy the condition. A user with no matching event gets a count of 0 (not NULL), which helps prevent division by zero in later calculations.From the user_events table, aggregate the counts of view, click, and purchase events per user in a wide format. Return user_id, view_cnt, click_cnt, purchase_cnt in ascending user_id order.
| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 2024-01-10 10:00 |
| 1 | click | 2024-01-10 10:05 |
| 1 | purchase | 2024-01-10 10:30 |
| 2 | view | 2024-01-10 11:00 |
| 2 | click | 2024-01-10 11:10 |
| 3 | view | 2024-01-11 09:00 |
| 3 | view | 2024-01-11 09:15 |
| 4 | view | 2024-01-11 14:00 |
| 4 | click | 2024-01-11 14:20 |
| 4 | purchase | 2024-01-11 14:50 |
Expected output (ascending user_id):
| user_id | view_cnt | click_cnt | purchase_cnt |
|---|---|---|---|
| 1 | 1 | 1 | 1 |
| 2 | 1 | 1 | 0 |
| 3 | 2 | 0 | 0 |
| 4 | 1 | 1 | 1 |
user3 triggered view twice. user2 and user3 have no purchase, so the count is 0. Note that a missing event produces 0, not NULL.
SELECT user_id, COUNT(*) FILTER (WHERE event_type = 'view') AS view_cnt, -- Count conditionally by type COUNT(*) FILTER (WHERE event_type = 'click') AS click_cnt, COUNT(*) FILTER (WHERE event_type = 'purchase') AS purchase_cnt FROM user_events GROUP BY user_id ORDER BY user_id; /* Execution order (logical SQL evaluation order): 1. FROM user_events → Read the rows 2. GROUP BY user_id → Form groups 3. COUNT(*) FILTER (...) → Count conditionally by event type 4. ORDER BY user_id → Sort and return the output */
LEGEND
① FROM user_events (10 rows)
FROM user_eventsRead the 10 rows from the user_events table. There are three event types—view, click, and purchase—and the goal is to expand them into columns.| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 2024-01-10 10:00 |
| 1 | click | 2024-01-10 10:05 |
| 1 | purchase | 2024-01-10 10:30 |
| 2 | view | 2024-01-10 11:00 |
| 2 | click | 2024-01-10 11:10 |
| 3 | view | 2024-01-11 09:00 |
| 3 | view | 2024-01-11 09:15 |
| 4 | view | 2024-01-11 14:00 |
| 4 | click | 2024-01-11 14:20 |
| 4 | purchase | 2024-01-11 14:50 |
COUNT(*) FILTER (WHERE event_type = 'view') counts nonmatching rows as 0 and does not return NULL, so a later division does not need a NULLIF guard. SUM(CASE WHEN ... THEN 1 ELSE 0 END) is equivalent, but FILTER is more readable and easier for the PostgreSQL optimizer to optimize.COUNT(*). To count users who triggered an event at least once, use COUNT(DISTINCT user_id) FILTER (...). Be explicit about the analytical goal: event frequency or unique-user count.WHERE view_cnt > 0 AND purchase_cnt = 0.GROUP BY user_id is required.jsonb_object_agg(event_type, cnt) or crosstab().ROW_NUMBER() is a basic window function that assigns a sequence number to each row according to the specified PARTITION BY and ORDER BY. This makes it possible to extract each user's first event with the CTE + WHERE rn = 1 pattern.
ROW_NUMBER() OVER ( PARTITION BY user_id -- Reset for each user ORDER BY event_time ASC -- Oldest first → rn=1 is the first event ) -- ASC → rn=1 is the first event (first touch) -- DESC → rn=1 is the last event (last touch)
ROW_NUMBER() preserves the original row count and adds a calculated sequence number to each row. A standard pattern is then to wrap it in a CTE and filter with WHERE rn = 1. Because a window-function result cannot be referenced directly in WHERE, a CTE or subquery is required.From the user_events table, extract exactly one row for the first event for each user (event_type and event_time). Return user_id, first_event_type, first_event_time in ascending user_id order.
| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 2024-01-10 10:00 |
| 1 | signup | 2024-01-10 10:30 |
| 1 | purchase | 2024-01-10 11:00 |
| 2 | signup | 2024-01-11 09:00 |
| 2 | purchase | 2024-01-11 09:45 |
| 3 | view | 2024-01-12 14:00 |
| 3 | purchase | 2024-01-12 15:00 |
| 4 | view | 2024-01-13 10:00 |
※ user2 did not have a view; signup is its first event (direct signup from a landing page).
Expected output (ascending user_id):
| user_id | first_event_type | first_event_time |
|---|---|---|
| 1 | view | 2024-01-10 10:00 |
| 2 | signup | 2024-01-11 09:00 |
| 3 | view | 2024-01-12 14:00 |
| 4 | view | 2024-01-13 10:00 |
user2's first event is signup. Only the first row for each user is retained.
WITH ranked AS ( SELECT user_id, event_type, event_time, ROW_NUMBER() OVER ( -- Window function: no GROUP BY PARTITION BY user_id -- Reset numbering for each user ORDER BY event_time ASC -- Number oldest first → rn=1 is the first ) AS rn FROM user_events ) SELECT user_id, event_type AS first_event_type, event_time AS first_event_time FROM ranked WHERE rn = 1 -- Keep only the first row for each user ORDER BY user_id; /* Execution order (logical SQL evaluation order): 1. CTE ranked 2. Outer query 3. SELECT event_type AS first_event_type → Rename the column 4. ORDER BY user_id → Ascending user_id */
LEGEND
① FROM user_events (8 rows)
FROM user_eventsRead the 8 rows from user_events. Each user has multiple events, and the goal is to extract only the first row for each user.| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 2024-01-10 10:00 |
| 1 | signup | 2024-01-10 10:30 |
| 1 | purchase | 2024-01-10 11:00 |
| 2 | signup | 2024-01-11 09:00 |
| 2 | purchase | 2024-01-11 09:45 |
| 3 | view | 2024-01-12 14:00 |
| 3 | purchase | 2024-01-12 15:00 |
| 4 | view | 2024-01-13 10:00 |
ROW_NUMBER assigns rn=1 to an arbitrary one (not deterministic), while RANK and DENSE_RANK assign the same number to ties, producing multiple rn=1 rows. Use ROW_NUMBER when you want exactly one row; use RANK when you want all tied rows.WHERE ROW_NUMBER() OVER (...) = 1 causes an error. Always wrap the query in a CTE or subquery, then filter with WHERE rn = 1.ORDER BY event_time DESC so rn=1 is the last event. The same pattern retrieves the user's last action (last touch).ROW_NUMBER() OVER (ORDER BY event_time), all users share one sequence. rn=1 is only the single oldest event in the whole table, not the first event for each user.ORDER BY event_time, event_id.first_event_type = 'signup' (who skipped view and registered directly) may have arrived through referral links or social-media ads. Joining the first event extracted by ROW_NUMBER() with campaign IDs and referrer information lets you analyze which acquisition channels bring in users with high LTV.LAG() is a window function that references the value from the row immediately before the current row. In event modeling, it is used to calculate the time since the previous event and define a new session when the gap reaches a threshold such as 30 minutes.
LAG(event_time) OVER ( PARTITION BY user_id -- Never reference the previous row across users ORDER BY event_time -- Take the previous row after chronological sorting ) -- Convert the timestamp difference to minutes EXTRACT(EPOCH FROM (event_time - prev_event_time)) / 60 -- EPOCH = seconds; divide by 60 to convert to minutes
CASE WHEN prev_event_time IS NULL THEN true treats the first event as the start of a new session every time.From the user_events table, calculate the gap in minutes from the previous event and add a session-start flag that is true when the gap is at least 30 minutes or the event is the first one. Return user_id, event_type, event_time, prev_event_time, gap_min, is_new_session in ascending user_id / event_time order.
| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 2024-01-10 10:00 |
| 1 | click | 2024-01-10 10:03 |
| 1 | view | 2024-01-10 10:35 |
| 1 | purchase | 2024-01-10 10:40 |
| 2 | view | 2024-01-10 11:00 |
| 2 | click | 2024-01-10 11:05 |
| 2 | view | 2024-01-10 12:10 |
※ user1: 10:03 (3 minutes later) → same session; 10:35 (32 minutes later) → new session. user2: 11:05 (5 minutes later) → same session; 12:10 (65 minutes later) → new session.
Expected output (ascending user_id / event_time):
| user_id | event_type | event_time | prev_event_time | gap_min | is_new_session |
|---|---|---|---|---|---|
| 1 | view | 10:00 | NULL | NULL | true |
| 1 | click | 10:03 | 10:00 | 3 | false |
| 1 | view | 10:35 | 10:03 | 32 | true |
| 1 | purchase | 10:40 | 10:35 | 5 | false |
| 2 | view | 11:00 | NULL | NULL | true |
| 2 | click | 11:05 | 11:00 | 5 | false |
| 2 | view | 12:10 | 11:05 | 65 | true |
WITH with_lag AS ( SELECT user_id, event_type, event_time, LAG(event_time) OVER ( -- Reference the previous event_time PARTITION BY user_id -- Do not cross user boundaries ORDER BY event_time -- Chronological order ) AS prev_event_time FROM user_events ) SELECT user_id, event_type, event_time, prev_event_time, ROUND( EXTRACT(EPOCH FROM (event_time - prev_event_time)) / 60 -- Convert seconds to minutes )::int AS gap_min, CASE WHEN prev_event_time IS NULL -- First event OR EXTRACT(EPOCH FROM (event_time - prev_event_time)) / 60 >= 30 -- Gap of at least 30 minutes THEN true ELSE false END AS is_new_session FROM with_lag ORDER BY user_id, event_time; /* Execution order (logical SQL evaluation order): 1. CTE with_lag 2. Outer query 3. ORDER BY user_id, event_time → Chronological order */
LEGEND
① FROM user_events (7 rows)
FROM user_eventsRead the 7 rows from user_events. user1 has 4 event rows and user2 has 3. Process them as chronological sequences within each user.| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 10:00 |
| 1 | click | 10:03 |
| 1 | view | 10:35 |
| 1 | purchase | 10:40 |
| 2 | view | 11:00 |
| 2 | click | 11:05 |
| 2 | view | 12:10 |
LAG(event_time) OVER (ORDER BY event_time) and omit PARTITION BY, user1's first row may reference user2's last event as the “previous row.” Always add PARTITION BY user_id when analyzing event logs.event_time - prev_event_time is PostgreSQL's interval type. An interval cannot be compared with a numeric value such as >= 30, so EXTRACT(EPOCH FROM interval) converts it to seconds (float) before comparison. Divide by 60 for minutes, 3600 for hours, or 86400 for days.SUM(is_new_session::int) OVER (PARTITION BY user_id ORDER BY event_time) yields the cumulative session count within each user, which serves directly as a session ID.event_time AT TIME ZONE 'Asia/Tokyo' before calculating LAG.SUM(is_new_session::int) OVER (PARTITION BY user_id ORDER BY event_time ROWS UNBOUNDED PRECEDING) generates session numbers. Once session IDs are available, you can calculate session-level metrics rather than page-view metrics, such as the average number of events per session, the distribution of session lengths, and conversion rates within a session.LEAD() is the opposite-direction window function to LAG(): it references the value from the row immediately after the current row. By retrieving the event that occurs next, you can aggregate user behavior paths (transition patterns).
LEAD(event_type) OVER ( PARTITION BY user_id -- Never reference the next row across users ORDER BY event_time -- Take the next row after chronological sorting ) -- The last event has no next row, so it returns NULL
From the user_events table, aggregate the transition patterns (from_event → to_event) for the event immediately following each event. Exclude final events (to_event = NULL), and return from_event, to_event, transition_count ordered by transition_count descending and from_event ascending.
| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 2024-01-10 10:00 |
| 1 | click | 2024-01-10 10:05 |
| 1 | view | 2024-01-10 10:10 |
| 1 | purchase | 2024-01-10 10:30 |
| 2 | view | 2024-01-11 09:00 |
| 2 | click | 2024-01-11 09:10 |
| 2 | view | 2024-01-11 09:20 |
| 2 | click | 2024-01-11 09:35 |
| 3 | view | 2024-01-12 14:00 |
| 3 | purchase | 2024-01-12 14:30 |
Expected output (transition_count descending / from_event ascending):
| from_event | to_event | transition_count |
|---|---|---|
| view | click | 3 |
| click | view | 2 |
| view | purchase | 2 |
view→click occurs 3 times: once for user1 plus twice for user2, making it the most common. click→view and view→purchase occur twice each. Final transitions (purchase→NULL and click→NULL) are excluded.
WITH transitions AS ( SELECT user_id, event_type AS from_event, LEAD(event_type) OVER ( -- Reference the next event type PARTITION BY user_id -- Do not cross user boundaries ORDER BY event_time -- Chronological order ) AS to_event FROM user_events ) SELECT from_event, to_event, COUNT(*) AS transition_count FROM transitions WHERE to_event IS NOT NULL -- Exclude final events (no next event) GROUP BY from_event, to_event ORDER BY transition_count DESC, from_event; /* Execution order (logical SQL evaluation order): 1. CTE transitions 2. Outer query 3. ORDER BY transition_count DESC, from_event → Most frequent first, then alphabetical */
LEGEND
① FROM user_events (10 rows)
FROM user_eventsRead 10 rows from user_events. user1 and user2 each have 4-event sequences, while user3 has 2 events. LEAD() processes the chronological sequence within each user.| user_id | event_type | event_time |
|---|---|---|
| 1 | view | 10:00 |
| 1 | click | 10:05 |
| 1 | view | 10:10 |
| 1 | purchase | 10:30 |
| 2 | view | 09:00 |
| 2 | click | 09:10 |
| 2 | view | 09:20 |
| 2 | click | 09:35 |
| 3 | view | 14:00 |
| 3 | purchase | 14:30 |
LEAD(event_type, 2) retrieves the event two rows later. The third argument is a default value; LEAD(event_type, 1, 'end') replaces the final row's NULL with 'end'. For transition analysis, excluding with IS NOT NULL is usually clearer, but a default is useful when the final state must be explicit.transition_count * 1.0 / SUM(transition_count) OVER (PARTITION BY from_event) calculates conditional probabilities such as the probability that click follows view.LEAD(event_type) OVER (ORDER BY event_time), the “next” event after user1's final event is user2's first event. Nonexistent transitions enter the data and distort the analysis.DAU (Daily Active Users) and MAU (Monthly Active Users) are among the most important metrics in event modeling. Use DATE_TRUNC to round event_time to a day or month, then use COUNT(DISTINCT user_id) to calculate the number of unique users.
| Metric | DATE_TRUNC granularity | Meaning |
|---|---|---|
| DAU | 'day' | Number of unique users who generated at least one event that day |
| WAU | 'week' | Number of unique users who generated at least one event that week |
| MAU | 'month' | Number of unique users who generated at least one event that month |
From the user_events table, calculate ① daily DAU (daily active users) and ② monthly MAU (monthly active users) separately. Return activity_date, dau for ① and activity_month, mau for ②, ordered by activity_date / activity_month ascending.
| user_id | event_time |
|---|---|
| 1 | 2024-01-10 10:00 |
| 1 | 2024-01-10 14:00 |
| 2 | 2024-01-10 11:00 |
| 3 | 2024-01-11 09:00 |
| 1 | 2024-01-11 15:00 |
| 2 | 2024-01-12 10:00 |
| 4 | 2024-01-12 16:00 |
| 5 | 2024-02-01 09:00 |
| 1 | 2024-02-01 11:00 |
| 3 | 2024-02-02 10:00 |
※ January: user1 visits twice on the same day but DAU=1. January MAU=4 (users 1–4). February MAU=3 (users 1, 3, and 5).
① DAU (daily active users):
| activity_date | dau |
|---|---|
| 2024-01-10 | 2 |
| 2024-01-11 | 2 |
| 2024-01-12 | 2 |
| 2024-02-01 | 2 |
| 2024-02-02 | 1 |
Even though user1 was active twice on 2024-01-10, DAU=2 (user1+user2). COUNT DISTINCT removes duplicates.
② MAU (monthly active users):
| activity_month | mau |
|---|---|
| 2024-01-01 | 4 |
| 2024-02-01 | 3 |
January: users 1–2–3–4, so 4 users. February: users 1–3–5, so 3 users. Sum of January DAU (2+2+2=6) ≠ MAU (4).
-- ① DAU: Round event_time to the day and count unique users SELECT DATE_TRUNC('day', event_time)::date AS activity_date, COUNT(DISTINCT user_id) AS dau FROM user_events GROUP BY activity_date ORDER BY activity_date; -- ② MAU: Round event_time to the month and count unique users SELECT DATE_TRUNC('month', event_time)::date AS activity_month, COUNT(DISTINCT user_id) AS mau FROM user_events GROUP BY activity_month ORDER BY activity_month; /* Execution order (DAU query): 1. FROM user_events → Read the rows 2. DATE_TRUNC('day', ...) → Round event_time to the day 3. GROUP BY activity_date → Group by date 4. COUNT(DISTINCT user_id) → Count unique users per day 5. ORDER BY activity_date → Sort by date ascending Execution order (MAU query): 1. FROM user_events → Read the rows 2. DATE_TRUNC('month', ...) → Round event_time to the month 3. GROUP BY activity_month → Group by month 4. COUNT(DISTINCT user_id) → Count unique users per month 5. ORDER BY activity_month → Sort by month ascending */
LEGEND
① FROM user_events (10 rows)
FROM user_eventsRead 10 rows from user_events. Notice that user1 visits twice on the same day (2024-01-10); COUNT DISTINCT removes this duplicate.| user_id | event_time |
|---|---|
| 1 | 2024-01-10 10:00 |
| 1 | 2024-01-10 14:00 |
| 2 | 2024-01-10 11:00 |
| 3 | 2024-01-11 09:00 |
| 1 | 2024-01-11 15:00 |
| 2 | 2024-01-12 10:00 |
| 4 | 2024-01-12 16:00 |
| 5 | 2024-02-01 09:00 |
| 1 | 2024-02-01 11:00 |
| 3 | 2024-02-02 10:00 |
LEGEND
① FROM user_events (10 rows)
FROM user_eventsUse the same user_events table, but round to months to calculate monthly active users (MAU). A user active on multiple days in January still counts once for the month.| user_id | event_time |
|---|---|
| 1 | 2024-01-10 10:00 |
| 1 | 2024-01-10 14:00 |
| 2 | 2024-01-10 11:00 |
| 3 | 2024-01-11 09:00 |
| 1 | 2024-01-11 15:00 |
| 2 | 2024-01-12 10:00 |
| 4 | 2024-01-12 16:00 |
| 5 | 2024-02-01 09:00 |
| 1 | 2024-02-01 11:00 |
| 3 | 2024-02-02 10:00 |
::date makes the output display as '2024-01-01' rather than '2024-01-01 00:00:00', which works better with downstream advertising and BI tools.COUNT(*) is the number of events. If one user triggers 10 events, COUNT(*)=10. Always use COUNT(DISTINCT user_id) for DAU/MAU.