Event Modeling — Learn Sessions, behavior paths, and DAU/MAU from the Basics
BasicEvent ModelingWindow FunctionsSessions / Behavior PathsDAU / MAUPostgreSQL Compatible5 questions
QUESTION 1
Event Pivot Aggregation — Expand event types with FILTER(WHERE ...) + GROUP BY
FILTER WHEREGROUP BYPivot TransformationEvent Aggregation
Background

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
FILTER works as a pre-count filter for COUNT: 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.
Problem

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.

Table
► user_events (10 rows)
user_idevent_typeevent_time
1view2024-01-10 10:00
1click2024-01-10 10:05
1purchase2024-01-10 10:30
2view2024-01-10 11:00
2click2024-01-10 11:10
3view2024-01-11 09:00
3view2024-01-11 09:15
4view2024-01-11 14:00
4click2024-01-11 14:20
4purchase2024-01-11 14:50
Expected Output

Expected output (ascending user_id):

user_idview_cntclick_cntpurchase_cnt
1111
2110
3200
4111

user3 triggered view twice. user2 and user3 have no purchase, so the count is 0. Note that a missing event produces 0, not NULL.

Model Answer
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
  */
Explanation (table transitions & key points)
SELECT user_id, COUNT(*) FILTER (WHERE event_type = 'view') AS view_cnt, 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;
LEGEND
Rows read / loaded
① 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.
1 / 5
user_idevent_typeevent_time
1view2024-01-10 10:00
1click2024-01-10 10:05
1purchase2024-01-10 10:30
2view2024-01-10 11:00
2click2024-01-10 11:10
3view2024-01-11 09:00
3view2024-01-11 09:15
4view2024-01-11 14:00
4click2024-01-11 14:20
4purchase2024-01-11 14:50
10 rows read (4 users × multiple events)
LEARNING POINTS
FILTER vs. SUM(CASE WHEN ...): 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.
Choosing between COUNT(*) and COUNT(DISTINCT user_id): This quiz counts how many times events occurred, so it uses 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.
Benefits of converting tall data to wide data: After the conversion, all of a user's behavior fits on one row, so the result can be used directly for user-behavior clustering and machine-learning feature engineering. A segment such as users who viewed but did not purchase can be written concisely as WHERE view_cnt > 0 AND purchase_cnt = 0.
ANTI-PATTERNS
Using FILTER without GROUP BY: Without GROUP BY, the entire table is treated as one group and the counts for all users are returned as totals. For per-user aggregation, GROUP BY user_id is required.
Maintenance becomes difficult as event_type grows: Each new event type requires a manual update to the SELECT list. If event_type is dynamic, consider jsonb_object_agg(event_type, cnt) or crosstab().
IN PRACTICE: Why event tables are normally designed in tall format
You may wonder, “Why not record events in wide format from the start?” Tall-format design has three advantages: ① new event types can be added without changing the schema (just add “refund” to the purchase log), ② properties such as amount and product ID can be attached flexibly to each event, and ③ it works well with streaming systems and message queues such as Kafka. Converting to wide format belongs at the analytical-query stage.
QUESTION 2
First-Event Extraction — Identify the first action with ROW_NUMBER() OVER (PARTITION BY ORDER BY)
ROW_NUMBEROVER PARTITION BYFirst EventWindow Function
Background

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)
Window functions return row-level results without GROUP BY: Unlike GROUP BY, 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.
Problem

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.

Table
► user_events (8 rows)
user_idevent_typeevent_time
1view2024-01-10 10:00
1signup2024-01-10 10:30
1purchase2024-01-10 11:00
2signup2024-01-11 09:00
2purchase2024-01-11 09:45
3view2024-01-12 14:00
3purchase2024-01-12 15:00
4view2024-01-13 10:00

※ user2 did not have a view; signup is its first event (direct signup from a landing page).

Expected Output

Expected output (ascending user_id):

user_idfirst_event_typefirst_event_time
1view2024-01-10 10:00
2signup2024-01-11 09:00
3view2024-01-12 14:00
4view2024-01-13 10:00

user2's first event is signup. Only the first row for each user is retained.

Model Answer
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
  */
Explanation (table transitions & key points)
WITH ranked AS ( SELECT user_id, event_type, event_time, ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY event_time ASC ) 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 ORDER BY user_id;
LEGEND
Rows read / loaded
① 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.
1 / 5
user_idevent_typeevent_time
1view2024-01-10 10:00
1signup2024-01-10 10:30
1purchase2024-01-10 11:00
2signup2024-01-11 09:00
2purchase2024-01-11 09:45
3view2024-01-12 14:00
3purchase2024-01-12 15:00
4view2024-01-13 10:00
8 rows read (4 users × multiple events)
LEARNING POINTS
Choosing among ROW_NUMBER, RANK, and DENSE_RANK: If multiple events have the same event_time, 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.
Why a window function cannot be referenced directly in WHERE: In SQL's logical execution order, WHERE is evaluated before window functions. Therefore, WHERE ROW_NUMBER() OVER (...) = 1 causes an error. Always wrap the query in a CTE or subquery, then filter with WHERE rn = 1.
Extracting the last event: Simply change to ORDER BY event_time DESC so rn=1 is the last event. The same pattern retrieves the user's last action (last touch).
ANTI-PATTERNS
Omitting PARTITION BY creates one sequence across the entire table: With 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 when multiple events share an event_time: Ordering only by event_time leaves the order among simultaneous events undefined. To guarantee a deterministic result, add a unique key such as ORDER BY event_time, event_id.
IN PRACTICE: Applying first-touch attribution analysis
Extracting the first event (first touch) directly supports a first-touch attribution model for measuring marketing effectiveness. For example, users with 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.
QUESTION 3
Session Analysis — Calculate event gaps with LAG() and detect session boundaries
LAGEXTRACT EPOCHSession AnalysisTime-Series Processing
Background

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
The first event has a NULL prev_event_time: Each user's first event has no previous row, so LAG() returns NULL. Using CASE WHEN prev_event_time IS NULL THEN true treats the first event as the start of a new session every time.
Problem

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.

Table
► user_events (7 rows)
user_idevent_typeevent_time
1view2024-01-10 10:00
1click2024-01-10 10:03
1view2024-01-10 10:35
1purchase2024-01-10 10:40
2view2024-01-10 11:00
2click2024-01-10 11:05
2view2024-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

Expected output (ascending user_id / event_time):

user_idevent_typeevent_timeprev_event_timegap_minis_new_session
1view10:00NULLNULLtrue
1click10:0310:003false
1view10:3510:0332true
1purchase10:4010:355false
2view11:00NULLNULLtrue
2click11:0511:005false
2view12:1011:0565true
Model Answer
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
  */
Explanation (table transitions & key points)
WITH with_lag AS ( SELECT user_id, event_type, event_time, LAG(event_time) OVER ( PARTITION BY user_id ORDER BY event_time ) 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 )::int AS gap_min, CASE WHEN prev_event_time IS NULL OR EXTRACT(EPOCH FROM (event_time - prev_event_time)) / 60 >= 30 THEN true ELSE false END AS is_new_session FROM with_lag ORDER BY user_id, event_time;
LEGEND
Rows read / loaded
① 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.
1 / 6
user_idevent_typeevent_time
1view10:00
1click10:03
1view10:35
1purchase10:40
2view11:00
2click11:05
2view12:10
7 rows read (user1=4 rows, user2=3 rows)
LEARNING POINTS
LAG without PARTITION BY crosses the entire table: If you write 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.
Why use EPOCH to turn timestamp differences into numbers: The result of 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.
Extending this to assign session IDs: Generate a session ID by numbering rows where is_new_session is true. 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.
ANTI-PATTERNS
Splitting sessions without accounting for time zones: If event_time is recorded in UTC but analyzed in JST (+9 hours), failing to convert the time zone can incorrectly split sessions across dates. Make it a habit to convert with event_time AT TIME ZONE 'Asia/Tokyo' before calculating LAG.
Hard-coding the session threshold: Thirty minutes is a Google Analytics convention, but the appropriate value depends on the product. A long-form video service may need 60–90 minutes, while an immediate chat service may need 5–10 minutes. Choose the threshold from the product's characteristics rather than treating it as a constant.
IN PRACTICE: Assigning session IDs and applying them to analysis
After detecting session boundaries, the next step is to assign a session ID to every row. A cumulative sum of 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.
QUESTION 4
Event Transition Analysis — Aggregate behavior paths by referencing the next event with LEAD()
LEADCTETransition-Path AnalysisBehavior Sequence
Background

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
Aggregating transition pairs is central to behavior analysis: Counting (from_event → to_event) pairs produces a frequency matrix showing what happens after each event. Filtering with WHERE to_event IS NOT NULL to remove the final row is essential. Without it, meaningless pairs such as “purchase→NULL” and “click→NULL” enter the result.
Problem

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.

Table
► user_events (10 rows)
user_idevent_typeevent_time
1view2024-01-10 10:00
1click2024-01-10 10:05
1view2024-01-10 10:10
1purchase2024-01-10 10:30
2view2024-01-11 09:00
2click2024-01-11 09:10
2view2024-01-11 09:20
2click2024-01-11 09:35
3view2024-01-12 14:00
3purchase2024-01-12 14:30
Expected Output

Expected output (transition_count descending / from_event ascending):

from_eventto_eventtransition_count
viewclick3
clickview2
viewpurchase2

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.

Model Answer
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
  */
Explanation (table transitions & key points)
WITH transitions AS ( SELECT user_id, event_type AS from_event, LEAD(event_type) OVER ( PARTITION BY user_id ORDER BY event_time ) AS to_event FROM user_events ) SELECT from_event, to_event, COUNT(*) AS transition_count FROM transitions WHERE to_event IS NOT NULL GROUP BY from_event, to_event ORDER BY transition_count DESC, from_event;
LEGEND
Rows read / loaded
① 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.
1 / 6
user_idevent_typeevent_time
1view10:00
1click10:05
1view10:10
1purchase10:30
2view09:00
2click09:10
2view09:20
2click09:35
3view14:00
3purchase14:30
10 rows read (user1=4, user2=4, user3=2)
LEARNING POINTS
The three-argument form LEAD(expr, offset, default): 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.
Converting transitions to probabilities (a Markov chain): Divide each transition count by the total number of transitions for its from_event. transition_count * 1.0 / SUM(transition_count) OVER (PARTITION BY from_event) calculates conditional probabilities such as the probability that click follows view.
Chaining multi-step transitions: Use LEAD(event_type, 1) and LEAD(event_type, 2) together to obtain three-step sequences such as “A → B → C”. Grouping them reveals the most common three-event patterns and helps identify core-user behavior loops.
ANTI-PATTERNS
Omitting PARTITION BY mixes transitions across users: With 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.
Forgetting WHERE to_event IS NOT NULL adds trailing noise: Without the filter, “purchase→NULL” and “click→NULL” appear, causing NULL—an absence of an event—to be aggregated as an event name and steering the analysis in the wrong direction.
IN PRACTICE: Predicting behavior with a Markov-chain model
Transition frequencies converted into transition probabilities form a first-order Markov-chain model. Its assumption—that the next event can be predicted from only the current event—is simple, but it is often accurate enough for practical path analysis. Understanding transition analysis in SQL directly supports the design of machine-learning pipelines.
QUESTION 5
Calculating DAU / MAU — Aggregate daily and monthly active-user metrics with DATE_TRUNC
DATE_TRUNCCOUNT DISTINCTDAU / MAUActive-User Metrics
Background

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.

MetricDATE_TRUNC granularityMeaning
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
Summing DAU does not produce MAU: If the same user is active on multiple days in a month, the DAU sum counts that user multiple times. Always round event_time to the month and then use COUNT(DISTINCT user_id) to calculate MAU. This deduplication is the core purpose of COUNT DISTINCT.
Problem

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.

Table
► user_events (10 rows)
user_idevent_time
12024-01-10 10:00
12024-01-10 14:00
22024-01-10 11:00
32024-01-11 09:00
12024-01-11 15:00
22024-01-12 10:00
42024-01-12 16:00
52024-02-01 09:00
12024-02-01 11:00
32024-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).

Expected Output

① DAU (daily active users):

activity_datedau
2024-01-102
2024-01-112
2024-01-122
2024-02-012
2024-02-021

Even though user1 was active twice on 2024-01-10, DAU=2 (user1+user2). COUNT DISTINCT removes duplicates.

② MAU (monthly active users):

activity_monthmau
2024-01-014
2024-02-013

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).

Model Answer
-- ① 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
  */
Explanation (table transitions & key points)
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;
LEGEND
Rows read / loaded
① 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.
1 / 5
user_idevent_time
12024-01-10 10:00
12024-01-10 14:00
22024-01-10 11:00
32024-01-11 09:00
12024-01-11 15:00
22024-01-12 10:00
42024-01-12 16:00
52024-02-01 09:00
12024-02-01 11:00
32024-02-02 10:00
10 rows read
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;
LEGEND
Rows read / loaded
① 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.
1 / 5
user_idevent_time
12024-01-10 10:00
12024-01-10 14:00
22024-01-10 11:00
32024-01-11 09:00
12024-01-11 15:00
22024-01-12 10:00
42024-01-12 16:00
52024-02-01 09:00
12024-02-01 11:00
32024-02-02 10:00
10 rows read
LEARNING POINTS
Switch DAU/WAU/MAU by changing DATE_TRUNC's granularity: 'day' → DAU, 'week' → WAU, and 'month' → MAU. Only the first-argument granularity string changes; the query structure stays the same for every period. 'quarter' supports quarterly aggregation in the same way.
The mathematical meaning of total DAU ≠ MAU: January DAU is 2+2+2=6 while MAU=4 because user1 and user2 were active on multiple days and were counted repeatedly. The DAU/MAU relationship indicates the average number of days per month that users are active (stickiness).
Why the ::date cast is useful: DATE_TRUNC() returns a timestamp. Casting it with ::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.
ANTI-PATTERNS
Confusing event count with active-user count by using COUNT(*): COUNT(*) is the number of events. If one user triggers 10 events, COUNT(*)=10. Always use COUNT(DISTINCT user_id) for DAU/MAU.
Trying to derive MAU by summing DAU: January DAU is (2+2+2)=6, but MAU=4 because users active on multiple days are counted repeatedly. MAU is never the sum of DAU; calculate it independently with a separate query or CTE.
IN PRACTICE: From DAU/MAU to stickiness
Once both DAU and MAU are available, their ratio gives stickiness (DAU/MAU × 100). “Average daily active users per month ÷ monthly active users” shows how many days per month users use the service on average. A rough benchmark is 50% or more for social products and 20–40% for SaaS tools. Analyzing this metric by cohort and feature clarifies which user segments use the product most frequently.