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:
- gaps — attach the previous event's timestamp to each row using
LAG. - 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). - sessioned — assign a session ID per actor using a running
SUMover the start-flag. Every time a new session starts, the sum increments by 1. - 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.