N056-H1 Tier 4 · Advanced · hard analytics · Streamhub

Return each `event_type`, calendar month, number of `events` of that type in that month, and number of `events` of the same type in the immediately preceding month

Part of Period-over-Period Analysis in SQL

The problem

Scenario: Streamhub's product team tracks monthly event volume separately for each kind of activity, comparing each event_type to its own prior month rather than to a different event_type.

Task: Write a query to return each event_type, calendar month, number of events of that type in that month, and number of events of the same type in the immediately preceding month.

Assumptions:

  • An event_type is present in a month only if at least one event of that type was recorded in that month.
  • Each event_type's prev_month_count is drawn solely from that same event_type's earlier history.
  • The earliest month in each event_type's own history has no preceding month within that type; its prev_month_count value is missing.

Output:

  • One row per (event_type, month) pair present in the data.
  • Columns in this order: event_type, month (the first day of the calendar month), event_count, prev_month_count.
  • Sorted by event_type ascending, then month 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
SELECT
  event_type,
  DATE_TRUNC('month', occurred_at)::date AS MONTH,
  COUNT(*) AS event_count,
  LAG(COUNT(*)) OVER (
    PARTITION BY
      event_type
    ORDER BY
      DATE_TRUNC('month', occurred_at)
  ) AS prev_month_count
FROM
  events
GROUP BY
  event_type,
  DATE_TRUNC('month', occurred_at)
ORDER BY
  event_type,
  MONTH

The shape

The series isn't a single timeline — it's one timeline per event_type, running in parallel. PARTITION BY event_type slices the window so the prior-month lookback for one type cannot reach across into a different type's history, and LAG resets to NULL at every type boundary.

Clause by clause

  • SELECT event_type, DATE_TRUNC('month', occurred_at)::date AS month, COUNT(*) AS event_count, LAG(COUNT(*)) OVER (PARTITION BY event_type ORDER BY DATE_TRUNC('month', occurred_at)) AS prev_month_count returns each type's month bucket, that month's event count, and the same type's prior-month count. PARTITION BY event_type isolates the lookback to a single type; ORDER BY DATE_TRUNC('month', occurred_at) sets the chronological sequence inside that type's partition.
  • FROM events reads every recorded event.
  • GROUP BY event_type, DATE_TRUNC('month', occurred_at) produces one row per (type, month) pair so COUNT(*) becomes the per-type monthly total.
  • ORDER BY event_type, month prints each type's series as a contiguous block, in time order.

Why partitioning on the comparison entity, not on the date

The partition isolates rows that should be compared against each other; the order defines the sequence within that isolation. The comparison entity is the event_type — that's what each prior-month lookup should stay inside. The date is the order — it sets which row is "one back" within a type's partition. Reversing the two would partition by month and order by type, which makes no analytical sense: it would slice the data into one partition per month, with one row per type inside each partition, and the "prior" lookup would walk across alphabetically adjacent types instead of across time.

The trap

Dropping PARTITION BY event_type doesn't raise an error. LAG will still return a value for every row except the first, and the column will look populated. What it returns is the previous row in the global sequence, which means the first month of click will pull the last month of add_to_cart (or whichever type comes before it in the ordering) as its prior value. The numbers are real numbers, the report renders cleanly, and every type-to-type boundary is silently wrong. The bug has no surface symptom — it shows up only when a stakeholder checks an individual type's series against the database directly and notices the prior-month value doesn't match.

You practiced partitioning LAG by event_type so each type's prior-month count is drawn from its own series, with the lookback resetting at every type boundary.

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.