N065-M1 Tier 5 · Expert · medium analytics · Streamhub

Return every event that is the first event recorded in its session — those with no preceding event in the same session — returning the event's `id`, `session_id`, `event_type`, and `occurred_at`

Part of Sessionization and Funnel Analysis Patterns in SQL

The problem

Scenario: Streamhub's product team is analyzing what brings users into a session by examining which event opens each session.

Task: Write a query to return every event that is the first event recorded in its session — those with no preceding event in the same session — returning the event's id, session_id, event_type, and occurred_at.

Assumptions:

  • Within a session, events are ordered by occurred_at ascending.
  • An event qualifies as the first event in its session when no other event in the same session has an earlier occurred_at.
  • The result covers only first-of-session events.

Output:

  • One row per qualifying event.
  • Columns in this order: event_id, session_id, event_type, occurred_at.
  • Sorted by session_id ascending.
Schema · analytics 5 tables
users
id integer
name text
email text
country text
plan text
signed_up_at timestamptz
is_active boolean
conversions
id integer
user_id integer
converted_at timestamptz
plan text
amount numeric
sessions
id integer
user_id integer
started_at timestamptz
ended_at? timestamptz
event_count integer
events
id integer
user_id integer
session_id? integer
event_type text
occurred_at timestamptz
properties? jsonb
periods
id integer
name text
start_month integer
end_month integer

Run previews · Check grades

Write a query, then run it to see results here.

Worked solution Try it yourself first
Solution query
WITH
  event_gaps AS (
    SELECT
      id,
      session_id,
      event_type,
      occurred_at,
      LAG(occurred_at) OVER (
        PARTITION BY
          session_id
        ORDER BY
          occurred_at
      ) AS prev_occurred_at
    FROM
      events
  )
SELECT
  id AS event_id,
  session_id,
  event_type,
  occurred_at
FROM
  event_gaps
WHERE
  prev_occurred_at IS NULL
ORDER BY
  session_id

The shape

The first event in each session is the one whose LAG(occurred_at) is NULL — there is no prior event inside the partition to look back to. Computing the gap in a CTE and then filtering for prev_occurred_at IS NULL keeps the logic in two clean stages.

Clause by clause

  • The event_gaps CTE attaches LAG(occurred_at) OVER (PARTITION BY session_id ORDER BY occurred_at) AS prev_occurred_at to every event row. Inside each session, LAG returns the prior event's timestamp, except for the earliest event, where it returns NULL because no prior row exists in the partition.
  • The outer SELECT id AS event_id, session_id, event_type, occurred_at FROM event_gaps WHERE prev_occurred_at IS NULL keeps only the rows where the look-back was empty, which are exactly the session-opening events.
  • ORDER BY session_id sorts the result the way the prompt asks.

Why this and not a per-session MIN(occurred_at) join

You could compute MIN(occurred_at) per session_id in a CTE, then join events back on (session_id, occurred_at) to recover the opening event's full row. That works, but it does two passes over the data: one to find the minimums, one to look the rows back up. The LAG approach makes one pass and uses the NULL that LAG already produces at each partition boundary as the filter, which is exactly the marker we need.

The trap

Filtering on occurred_at = MIN(occurred_at) without a window or a join scope error is the easy mistake. A bare aggregate in WHERE does not compile, and pushing it to HAVING collapses each session to one row but loses the other event columns. The LAG IS NULL pattern sidesteps the grouping question entirely: every event row stays intact, and the filter selects on a per-row computed column rather than a group aggregate.

You practiced flagging session-opening events by checking whether LAG returns a missing value within the partition — the missing-value lookback identifies the row that has no predecessor in its session.

How you actually get good at SQL

Reading explains SQL. Writing it, over and over with instant feedback, is what makes you fluent.

That's the whole SQLMaxx loop: 600+ real problems, instant AI feedback, mastery you can actually see, and spaced review that won't let you forget.

A stack of SQL practice problem cards, the top card showing an employees table.
615 problems · 66 concepts

Real problems. Not toy examples.

615 hand-built problems spanning all 66 concepts, from basic SELECTs to window functions, built on real schemas and real business questions, the kind you'll actually get asked on the job. Enough reps to make SQL automatic.

A retro computer showing a SQL query marked correct with a green checkmark.
Instant AI feedback

Write a query. Know if it's right in one second.

No copying an answer and hoping it clicked. The AI grader checks your real query against real data, catches exactly what's wrong, and explains the fix in plain English, like a senior analyst reading over your shoulder on every problem.

A circular mastery progress dial filling from blue to green, the SQLMaxx diamond at its center.
Mastery tracking

Stop guessing whether you actually know it.

SQLMaxx tracks every concept and shows you what you've mastered and what's still shaky. Your skills fill in one concept at a time, so 'I think I get joins' becomes something you can prove.

A SQL query editor circled by a blue return arrow with a clock, scheduled to come back for review.
Spaced review

Learn it once. Keep it for good.

Most of what you learn this week fades by next week. So when a concept comes due for review, SQLMaxx hands you a fresh problem to solve from a blank editor, not a flashcard to re-read. A research-backed spaced-repetition algorithm (FSRS) times each return for right before you'd forget, so your SQL is still there months later, when the interview or the job actually needs it.

Practice, feedback, mastery, review. That's the loop that turns reading into real skill.

Start free

No account, no credit card. Start solving in under a minute.