Lab 07 — Analytical SQL against the GitHub events dataset
Lesson 07 · Data Engineering with Python · ~45 min lab
Goal: by the end of this lab you will have (a) loaded the Lesson 02
Parquet output into DuckDB without copying any files, (b) written five queries using CTEs,
window functions, QUALIFY, and GROUPING SETS, and (c) read an
EXPLAIN plan and identified partition pruning in action.
Prerequisites
-
DuckDB installed.
data-engineering/sample-pipeline/requirements.txtalready pinsduckdb>=1.0,<2.0. If you haven't installed it yet:pip install -r data-engineering/sample-pipeline/requirements.txt -
Lesson 02 Parquet output exists. The lab reads partitioned Parquet files
that the Lesson 02 lab writes to
data-engineering/sample-pipeline/data/parquet/date=*/. If that directory is missing, run the Lesson 02 Parquet lab first to producedata/parquet/date=*/. -
A terminal where you can run
pythonand theduckdbCLI.
Step 1 — Confirm DuckDB is installed
python -c "import duckdb; print(duckdb.__version__)"
Expected: a version string in the 1.x range.
The lab uses both the interactive DuckDB CLI for ad-hoc queries (Steps 2–8) and Python for the final script (Step 9).
Step 2 — Open DuckDB on the Parquet partitions
From the repo root, change into the pipeline directory and open an in-memory DuckDB session:
cd data-engineering/sample-pipeline
duckdb
Inside the DuckDB prompt, create a view over the partitioned Parquet files:
CREATE OR REPLACE VIEW events AS
SELECT * FROM read_parquet('data/parquet/date=*/*.parquet', hive_partitioning => true);
SELECT COUNT(*) FROM events;
Expected: the row count matches the total written by the Lesson 02 lab. DuckDB scans
the Parquet files in place — nothing is copied or imported. The partition column
date (a string like '2024-01-01') and the payload columns
type, actor, repo, and created_at
are all available.
Quick schema check
Run DESCRIBE events; to confirm the column names and types before writing
the queries below.
Step 3 — Query 1: top event types overall (warm-up)
SELECT type, COUNT(*) AS n
FROM events
GROUP BY type
ORDER BY n DESC
LIMIT 10;
Expected: PushEvent dominates, followed by CreateEvent,
IssueCommentEvent, etc. This is the pandas-shaped query — the rest of
the lab moves beyond it.
Step 4 — Query 2: first event per actor (window function + QUALIFY)
SELECT
actor,
type,
created_at
FROM events
QUALIFY ROW_NUMBER() OVER (PARTITION BY actor ORDER BY created_at) = 1
ORDER BY created_at
LIMIT 20;
Expected: 20 rows, each a distinct actor's first event in the dataset. Notice you didn't
need a CTE — QUALIFY filters on the window result directly.
✓ Self-check: every actor value in the result should be unique.
Step 5 — Query 3: top 3 event types per actor (CTE + window)
WITH ranked AS (
SELECT
actor,
type,
COUNT(*) AS n,
ROW_NUMBER() OVER (PARTITION BY actor ORDER BY COUNT(*) DESC) AS rn
FROM events
GROUP BY actor, type
)
SELECT actor, type, n
FROM ranked
WHERE rn <= 3
ORDER BY actor, rn
LIMIT 30;
Expected: 30 rows, 3 per actor for the first 10 actors alphabetically.
✓ Self-check: rewrite the same logic using QUALIFY rn <= 3 instead
of the CTE + WHERE (one-line change). Enable timing with .timer on
and compare wall-clock times — they should be roughly equal.
Step 6 — Query 4: hourly events with a 7-row rolling window
WITH hourly AS (
SELECT
date_trunc('hour', created_at) AS hour,
COUNT(*) AS n
FROM events
GROUP BY 1
)
SELECT
hour,
n,
AVG(n) OVER (ORDER BY hour ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS rolling_7h_avg
FROM hourly
ORDER BY hour;
Expected: every hour gets a 7-hour trailing average. The first six rows of the result will have a smaller effective window (only the rows so far contribute).
✓ Self-check: verify the first row's rolling_7h_avg equals
n (window of 1). The explicit ROWS BETWEEN 6 PRECEDING AND CURRENT
ROW frame is what makes this a rolling average instead of a running total.
Step 7 — Query 5: GROUPING SETS for a multi-level dashboard
SELECT
date_trunc('day', created_at) AS day,
type,
COUNT(*) AS n,
GROUPING(date_trunc('day', created_at)) AS day_rolled_up,
GROUPING(type) AS type_rolled_up
FROM events
GROUP BY GROUPING SETS (
(date_trunc('day', created_at), type),
(date_trunc('day', created_at)),
(type),
()
)
ORDER BY day, type;
Expected: one query produces detail rows (day + type), per-day subtotals
(type IS NULL), per-type subtotals (day IS NULL), and one grand
total (both NULL). The GROUPING() flags tell you which dimension was rolled
up — useful for display logic ("show 'All days'" instead of NULL).
✓ Self-check: count the rows where day_rolled_up = 1 AND type_rolled_up = 1
— there should be exactly 1 (the grand total).
Step 8 — Read an EXPLAIN plan
EXPLAIN ANALYZE
SELECT actor, COUNT(*) AS n
FROM read_parquet('data/parquet/date=*/*.parquet', hive_partitioning => true)
WHERE date = '2024-01-15'
GROUP BY actor
ORDER BY n DESC
LIMIT 10;
Expected: the plan output shows a READ_PARQUET operator restricted to
one partition (date=2024-01-15) — partition pruning is
working. If you see a scan of all partitions, check that hive_partitioning =>
true is set in the read_parquet call; without it the engine doesn't
know the directory structure is partitioned.
✓ Self-check: remove the WHERE clause and re-run. The plan now scans
every partition. Compare the two wall-clock times — the pruned query should be
noticeably faster on larger datasets.
Step 9 — Wrap it in a Python script
Move the most useful query (top-3 event types per actor) into a reusable Python script.
Create data-engineering/sample-pipeline/transform/sql_demo.py:
import duckdb
con = duckdb.connect()
con.execute("""
CREATE OR REPLACE VIEW events AS
SELECT * FROM read_parquet(
'data/parquet/date=*/*.parquet',
hive_partitioning => true
);
""")
df = con.execute("""
WITH ranked AS (
SELECT
actor,
type,
COUNT(*) AS n,
ROW_NUMBER() OVER (PARTITION BY actor ORDER BY COUNT(*) DESC) AS rn
FROM events
GROUP BY actor, type
)
SELECT actor, type, n
FROM ranked
WHERE rn <= 3
ORDER BY actor, rn
LIMIT 30;
""").fetchdf()
print(df)
Run from data-engineering/sample-pipeline/:
python transform/sql_demo.py
Expected: a pandas DataFrame printed with 30 rows. DuckDB did all the analytical heavy lifting; Python only received the small result.
✓ Self-check: the boundary is clean — the Python script never loads the raw Parquet into memory. All filtering, grouping, and windowing happened inside DuckDB.
What you should leave with
- Five working analytical queries: simple aggregation, first-per-partition with QUALIFY, top-N with CTE + window, rolling average with explicit frame, and multi-level GROUPING SETS.
- An EXPLAIN plan you've read, with partition pruning confirmed.
- The instinct that "push the work to the engine, return only the small result" is the data-engineering productivity multiplier.
Troubleshooting
Common errors
-
Parser Error: syntax error at or near "QUALIFY"— you're on a DuckDB version older than 0.10. Re-install perdata-engineering/sample-pipeline/requirements.txt(duckdb>=1.0,<2.0). -
No files found matching "data/parquet/date=*/*.parquet"— the Lesson 02 lab hasn't been run. Run the Parquet lab from Module 1 first to producedata/parquet/date=*/. See the Lesson 02 lab guide for details. -
EXPLAIN shows all partitions scanned despite a
WHERE date = ...clause — confirmhive_partitioning => trueis set in yourread_parquet()call. Without it, DuckDB treats the path as a flat glob and scans everything. -
NOT INreturns zero rows unexpectedly — one of the subquery values is NULL. Switch toNOT EXISTS.