Lesson 07 SQL for Data Engineers
Theme:

Homework 07 — Solutions

Lesson 07 · Data Engineering with Python · worked solutions

All queries assume the events view has been created as shown in the homework preamble, pointing at data-engineering/sample-pipeline/data/parquet/date=*/*.parquet with hive_partitioning => true.

Solution to Task 1 — Sessions with LAG

WITH gaps AS (
  SELECT
    actor,
    created_at,
    LAG(created_at) OVER (PARTITION BY actor ORDER BY created_at) AS prev_at
  FROM events
),
flagged AS (
  SELECT
    actor,
    created_at,
    CASE
      WHEN prev_at IS NULL
        OR EXTRACT(EPOCH FROM (created_at - prev_at)) > 1800
      THEN 1 ELSE 0
    END AS is_session_start
  FROM gaps
),
sessioned AS (
  SELECT
    actor,
    created_at,
    SUM(is_session_start) OVER (
      PARTITION BY actor ORDER BY created_at
    ) AS session_id
  FROM flagged
)
SELECT
  actor,
  session_id,
  MIN(created_at) AS session_start,
  MAX(created_at) AS session_end,
  COUNT(*) AS n_events
FROM sessioned
GROUP BY actor, session_id
ORDER BY session_start DESC;

The query builds the result in four logical steps, each a named CTE:

  1. gaps — attach the previous event's timestamp to each row using LAG.
  2. flagged — mark each row as a session start if the gap to the previous event exceeds 1800 seconds (30 minutes), or if there is no previous event (prev_at IS NULL).
  3. sessioned — assign a session ID per actor using a running SUM over the start-flag. Every time a new session starts, the sum increments by 1.
  4. Final SELECT — aggregate within each (actor, session_id) group to compute start, end, and event count.

Common mistake

Computing the gap as created_at - LAG(created_at) and comparing it directly to an interval literal like INTERVAL '30 minutes' — this works on Postgres but the syntax varies across engines. The portable pattern is EXTRACT(EPOCH FROM (a - b)) > seconds, which returns a plain number and works on DuckDB, Snowflake, BigQuery, and Postgres alike.

The other classic error is forgetting that the first event for each actor has prev_at IS NULL. Without the explicit WHEN prev_at IS NULL THEN 1 branch, those rows would not be marked as session starts, and all of an actor's events would collapse into a single session.

Solution to Task 2 — Top-K with ties

SELECT
  actor,
  COUNT(*) AS n
FROM events
GROUP BY actor
QUALIFY RANK() OVER (ORDER BY COUNT(*) DESC) = 1
ORDER BY actor;

QUALIFY filters after the window function, so there is no need for a CTE. RANK() assigns the same rank to all tied actors, meaning every actor with the maximum event count receives rank 1 and is kept in the result.

Common mistake

Using ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC) = 1 instead of RANK(). This is the most common bug in "top-N" queries written by pandas natives. ROW_NUMBER() assigns 1, 2, 3, … with arbitrary tie-breaking: if three actors each have 142 events, exactly one of them gets rn = 1 and the other two get rn = 2, 3 — they are silently dropped from the result. Use RANK() to preserve ties (1, 1, 1, 4, …) or DENSE_RANK() for a no-gap ranking (1, 1, 1, 2, …). For "top 3 including ties" use RANK() <= 3 or DENSE_RANK() <= 3 depending on your tie semantics.

Solution to Task 3 — GROUPING SETS dashboard

SELECT
  date_trunc('day', created_at) AS day,
  type,
  COUNT(*) AS n,
  GROUPING(date_trunc('day', created_at)) = 1 AS day_total,
  GROUPING(type) = 1 AS type_total
FROM events
GROUP BY GROUPING SETS (
  (date_trunc('day', created_at), type),
  (date_trunc('day', created_at)),
  (type),
  ()
)
ORDER BY day NULLS LAST, type NULLS LAST;

GROUPING(col) returns 1 when the column was rolled up by the grouping set and 0 otherwise. Comparing it to 1 (i.e., GROUPING(...) = 1) yields a boolean flag. The ORDER BY ... NULLS LAST clause pushes the rollup rows (which have NULL for the rolled-up dimension) to the bottom of the result.

Common mistake

Trying to distinguish "the user actually had NULL for this dimension" from "GROUPING SETS rolled it up" by checking IS NULL. This fails the moment the data contains real NULL values in day or type. GROUPING(col) is the only correct signal: it returns 1 only when the column was rolled up, regardless of the underlying value.

The other frequent slip is forgetting NULLS LAST in the ORDER BY. Rollup rows have NULL for the rolled-up dimensions, and NULL sorts before or after real values depending on the engine's default — which is unpredictable without an explicit directive.

What to take forward

All three patterns — sessionisation with LAG, ties with RANK, and multi-level aggregation with GROUPING SETS — are the building blocks of every analytical dashboard you will be asked to write. Lesson 08 takes the same dataset and wraps these queries in a dbt project, making them testable, refreshable, and reusable across a real model dependency graph.