The analytical SQL toolkit pandas users miss: windows, CTEs, GROUPING SETS, QUALIFY.
Data Engineering Module 3 SQL Foundations
SELECT ... FROM ... WHERE ... GROUP BY ... JOIN ... — and that's it.
They're often unaware of: window functions, CTEs (WITH ...),
GROUPING SETS / ROLLUP / CUBE, QUALIFY,
UNION vs UNION ALL, EXISTS / NOT EXISTS,
LATERAL joins.
This lesson closes the gap.
CTEs read top-down, just like a Python function. Each WITH clause is a named subquery you can compose. Aliasing intermediate steps beats nesting subqueries 4 levels deep.
WITH hourly_events AS (
SELECT
date_trunc('hour', created_at) AS hour,
type,
COUNT(*) AS n
FROM events
GROUP BY 1, 2
),
top_types AS (
SELECT type, SUM(n) AS total
FROM hourly_events
GROUP BY type
ORDER BY total DESC
LIMIT 5
)
SELECT * FROM top_types;
OVER (PARTITION BY ... ORDER BY ...) adds rows of context without losing the row itself.
SELECT
actor,
created_at,
COUNT(*) OVER (PARTITION BY actor) AS lifetime_events,
ROW_NUMBER() OVER (PARTITION BY actor ORDER BY created_at) AS rn
FROM events;
Every row stays in the result. The window function attaches summary context alongside the original row — unlike GROUP BY, which collapses rows.
ROW_NUMBER(), RANK(), DENSE_RANK(), NTILE(N)
"Where does this row stand within its partition?"
LAG(col, n), LEAD(col, n), FIRST_VALUE(col), LAST_VALUE(col)
"What was the previous / next / first / last value in this partition?"
Any aggregate (SUM, AVG, COUNT, MIN, MAX) with OVER (...) — running totals, moving averages, partition shares.
-- Get the FIRST event per user
SELECT
actor, type, created_at,
ROW_NUMBER() OVER (PARTITION BY actor ORDER BY created_at) AS rn
FROM events
QUALIFY rn = 1;
QUALIFY filters after the window function — the same role WHERE plays for raw columns,
HAVING plays for GROUP BY aggregates.
Supported by DuckDB, Snowflake, BigQuery.
For Postgres / MySQL: wrap in a CTE and filter with WHERE.
SELECT
date_trunc('day', created_at) AS day,
type,
COUNT(*) AS n
FROM events
GROUP BY GROUPING SETS (
(day, type),
(day),
(type),
()
);
One query, four aggregation levels: by (day, type), by day, by type, grand total.
ROLLUP is hierarchical: (day, type) → (day) → ().
CUBE is the full cross-product of every subset.
Reach for plain UNION only when you know there are duplicates you want gone.
-- WRONG: blows up rows when actors join to commits
SELECT a.* FROM actors a
JOIN commits c ON c.actor_id = a.id;
-- RIGHT: keep one row per actor, filter on existence
SELECT a.* FROM actors a
WHERE EXISTS (SELECT 1 FROM commits c WHERE c.actor_id = a.id);
JOIN multiplies rows; EXISTS doesn't. If you only want to filter, prefer EXISTS. Also: NOT IN returns no rows if any subquery value is NULL — use NOT EXISTS instead.
You write SQL in one order; the engine evaluates it in another:
| You write | Engine evaluates |
|---|---|
| SELECT | 6 |
| FROM | 1 |
| WHERE | 2 |
| GROUP BY | 3 |
| HAVING | 4 |
| OVER (window) | 5 |
| ORDER BY | 7 |
| LIMIT | 8 |
The OVER clause sits at step 5 — after GROUP BY/HAVING, before SELECT/ORDER BY. That's why QUALIFY (post-window filter) is its own keyword.
Top 3 event types per actor — one query.
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;
GROUP BY + window function + post-window filter. Roughly 10 lines of SQL = 30 lines of pandas.
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;
Every engine has an EXPLAIN. Read it. The optimiser tells you what scans, joins, and aggregates it will run — and in what order.
If a query is slow, the plan is the first place to look.
Look for: partition pruning, join order, unexpected cross joins.