Event Pivot Aggregation — Expand event types with FILTER(WHERE ...) + GROUP BY
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 |
| user_id | view_cnt | click_cnt | purchase_cnt |
|---|---|---|---|
| 1 | 1 | 1 | 1 |
| 2 | 1 | 1 | 0 |
| 3 | 2 | 0 | 0 |
| 4 | 1 | 1 | 1 |
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().First-Event Extraction — Identify the first action with ROW_NUMBER() OVER (PARTITION BY ORDER BY)
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 |
| 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 |
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.Session Analysis — Calculate event gaps with LAG() and detect session boundaries
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 |
| 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, TO_CHAR(event_time, 'HH24:MI') AS event_time, TO_CHAR(prev_event_time, 'HH24:MI') AS 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.Event Transition Analysis — Aggregate behavior paths by referencing the next event with LEAD()
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 |
| from_event | to_event | transition_count |
|---|---|---|
| view | click | 3 |
| click | view | 2 |
| view | purchase | 2 |
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.Calculating DAU / MAU — Aggregate daily and monthly active-user metrics with DATE_TRUNC
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 |
SELECT DATE_TRUNC('day', ts_col) AS bucket, -- Grain: 'day' / 'week' / 'month' COUNT(DISTINCT id_col) AS uniq_cnt -- Unique count with duplicates removed FROM table_name GROUP BY DATE_TRUNC('day', ts_col) ORDER BY bucket;
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 |
① 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.
| activity_month | mau |
|---|---|
| 2024-01-01 | 4 |
| 2024-02-01 | 3 |
-- ① 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.