SQL in Depth
You already know enough SQL to pull rows and total them up. This chapter takes you to fluency — the level where you can join several tables without inflating your numbers, rank and run-total with window functions, structure a gnarly query so a teammate can read it, and reason about why one query is fast and another crawls. Every example here runs. Open Postgres or DuckDB from Course 2, paste, and watch the output.
We'll use one small dataset all chapter long, a GPU-rental ledger like the one from Course 2. Paste this into psql or DuckDB first; everything below builds on it.
CREATE TABLE customers (
customer_id INT,
name TEXT,
plan TEXT -- 'free', 'pro', or NULL if not set
);
INSERT INTO customers VALUES
(1, 'Ada', 'pro'),
(2, 'Lin', 'free'),
(3, 'Omar', 'pro'),
(4, 'Priya', NULL); -- signed up, never picked a plan
CREATE TABLE rentals (
rental_id INT,
customer_id INT,
gpu_model TEXT,
hours INT,
price_per_hour NUMERIC,
created_at DATE
);
INSERT INTO rentals VALUES
(101, 1, 'A100', 10, 2.50, '2026-06-01'),
(102, 1, 'H100', 4, 4.00, '2026-06-03'),
(103, 2, 'A100', 6, 2.50, '2026-06-02'),
(104, 3, 'H100', 8, 4.00, '2026-06-02'),
(105, 3, 'A100', 2, 2.50, '2026-06-05'),
(106, 3, 'H100', 5, 4.00, '2026-06-07');
-- note: customer 4 (Priya) has NO rentalsWhere you are
From Chapter 04 you have the core verbs: SELECT the columns you want, WHERE to filter rows, GROUP BY to collapse groups, and a sense of how the planner turns that into work on disk. That's the vocabulary. This chapter is the grammar — the constructs that turn "I can write a query" into "I can express almost any question, correctly, and know roughly what it costs." We go deep on five things: joins, aggregation, window functions, CTEs, and set operations, then finish with how to make it all fast.
Joins, deeply
A join stitches rows from two tables together by matching a key. The five shapes you need to know differ only in what happens to rows that don't find a match:
INNER JOIN keeps only rows that match on both sides. Priya has no rentals, so she vanishes:
SELECT c.name, r.gpu_model, r.hours
FROM customers c
INNER JOIN rentals r ON r.customer_id = c.customer_id
ORDER BY c.name, r.rental_id;Six rows — Ada (A100, H100), Lin (A100), Omar (H100, A100, H100). No Priya: an inner join silently drops the unmatched customer. That silent drop is the single most common source of "where did my rows go?" bugs.
LEFT JOIN keeps every row from the left table even when the right side has no match, filling the missing columns with NULL:
SELECT c.name, r.gpu_model, r.hours
FROM customers c
LEFT JOIN rentals r ON r.customer_id = c.customer_id
ORDER BY c.name, r.rental_id;Seven rows now — the same six, plus Priya | NULL | NULL. Use a LEFT JOIN whenever "no match" is a valid, meaningful answer you want to keep. To find customers with no rentals, add WHERE r.rental_id IS NULL — the classic "anti-join" pattern, which returns just Priya.
RIGHT JOIN is the mirror image (all right-table rows kept); most engineers just rewrite it as a LEFT JOIN with the tables flipped, because reading left-to-right is easier. FULL JOIN keeps unmatched rows from both sides — useful for reconciliation ("what's in A but not B, and vice versa"). CROSS JOIN has no ON clause at all; it pairs every left row with every right row (a Cartesian product), handy for generating grids like every customer × every month.
A self-join joins a table to itself — give it two aliases. Here we find pairs of rentals by the same customer on the same GPU:
SELECT a.customer_id, a.gpu_model,
a.rental_id AS first_rental, b.rental_id AS later_rental
FROM rentals a
JOIN rentals b
ON a.customer_id = b.customer_id
AND a.gpu_model = b.gpu_model
AND a.rental_id < b.rental_id; -- < avoids duplicate & self-pairsOne row: customer 3 rented H100 twice (rentals 104 and 106). The a.rental_id < b.rental_id condition is what keeps a row from pairing with itself and stops you getting each pair twice.
The fan-out trap (row explosion)
This is the join bug that quietly corrupts dashboards. When you join to a table that has many rows per key (one-to-many), each left row is duplicated once per match. If you then SUM a column from the left table, you sum it multiple times. Watch it happen.
Suppose each plan has a monthly fee, and we join customers to both their plan fee and their rentals:
CREATE TABLE plans (plan TEXT, monthly_fee NUMERIC);
INSERT INTO plans VALUES ('free', 0), ('pro', 50);
-- WRONG: monthly_fee gets duplicated once per rental
SELECT SUM(p.monthly_fee) AS total_fees
FROM customers c
JOIN plans p ON p.plan = c.plan
JOIN rentals r ON r.customer_id = c.customer_id;This returns 150, not 100. Ada's $50 fee is counted twice (she has 2 rentals) and Omar's $50 three times (3 rentals): 50×2 + 50×3 = 250… minus Lin's free plan, the math depends on the join, but the point stands — the fee got multiplied by the number of rentals. The grain of the result is "one row per rental," but you're summing a per-customer value across it.
The fix is to aggregate to a single grain before joining, so each customer contributes one row. Collapse rentals first, then join:
-- RIGHT: each customer is one row before we touch fees
SELECT SUM(p.monthly_fee) AS total_fees
FROM customers c
JOIN plans p ON p.plan = c.plan
WHERE c.customer_id IN (SELECT DISTINCT customer_id FROM rentals);100 — Ada $50 + Omar $50 (Lin is free, Priya has no rentals so isn't counted). Whenever you join then SUM, ask: "is the thing I'm summing at the same grain as the joined rows?" If a join multiplies rows, every additive metric from the other side inflates.
Join conditions use equality, and NULL = anything is NULL (not true), so rows with a NULL key never match — Priya's NULL plan won't join to plans even though there's no 'NULL' plan to match anyway. This is also why WHERE r.rental_id IS NULL (not = NULL) is the only correct way to test for missing rows after a LEFT JOIN.
Aggregation that holds up
GROUP BY collapses many rows into one per group, and aggregate functions (SUM, COUNT, AVG, MIN, MAX) summarize each group. The total spend per customer:
SELECT customer_id,
COUNT(*) AS num_rentals,
SUM(hours * price_per_hour) AS total_spend
FROM rentals
GROUP BY customer_id
ORDER BY customer_id;Customer 1 → 2 rentals, $41.00 (10×2.50 + 4×4.00). Customer 2 → 1 rental, $15.00. Customer 3 → 3 rentals, $50.00 (8×4 + 2×2.50 + 5×4). Every non-aggregated column in SELECT must appear in GROUP BY — that's the rule that keeps the result one row per group.
You can group by multiple columns to get a finer breakdown — spend per customer per GPU model:
SELECT customer_id, gpu_model,
SUM(hours * price_per_hour) AS spend
FROM rentals
GROUP BY customer_id, gpu_model
ORDER BY customer_id, gpu_model;WHERE vs HAVING trips people up. WHERE filters rows before grouping; HAVING filters groups after grouping (so it can reference aggregates). Find customers who spent over $40 on rentals after June 1:
SELECT customer_id, SUM(hours * price_per_hour) AS spend
FROM rentals
WHERE created_at >= '2026-06-01' -- per-row filter, runs first
GROUP BY customer_id
HAVING SUM(hours * price_per_hour) > 40; -- per-group filter, runs afterTwo rows: customer 1 ($41.00) and customer 3 ($50.00). You cannot put SUM(...) > 40 in WHERE — the sum doesn't exist yet at row-filter time. And you cannot put a plain row filter in HAVING efficiently — it'd scan rows you could have skipped. Filter early in WHERE; filter aggregates in HAVING.
COUNT(DISTINCT …) counts unique values, not rows — how many different GPU models each customer used:
SELECT customer_id,
COUNT(*) AS rentals,
COUNT(DISTINCT gpu_model) AS distinct_gpus
FROM rentals
GROUP BY customer_id;Customer 3 → 3 rentals but only 2 distinct GPUs (two H100 rentals collapse to one model). Note COUNT(*) counts all rows including NULLs, while COUNT(col) ignores NULLs in that column — a distinction that matters the moment your data has gaps.
Window functions
This is the construct that levels you up the most. A window function computes an aggregate-like value across a set of rows — but unlike GROUP BY, it keeps every original row. That's the whole idea: GROUP BY collapses, a window annotates.
GROUP BY | Window (OVER) | |
|---|---|---|
| Rows out | One per group | Same as rows in |
| You keep detail? | No — it's collapsed | Yes — every row survives |
| Typical use | Totals, summaries | Running totals, ranks, prev/next, "% of total" |
The syntax is a function followed by OVER (...). Inside the parentheses, PARTITION BY splits rows into groups (like GROUP BY, but the rows stay), and ORDER BY orders rows within each partition (which matters for running totals and ranks). Add each customer's total beside every one of their rentals:
SELECT rental_id, customer_id,
hours * price_per_hour AS spend,
SUM(hours * price_per_hour) OVER (PARTITION BY customer_id) AS customer_total
FROM rentals
ORDER BY customer_id, rental_id;All six rentals (none collapsed), and every one of customer 3's three rows shows customer_total = 50.00. The detail row and its group total sit side by side — impossible with a plain GROUP BY without a self-join.
Running totals and the window frame
Add ORDER BY inside OVER and the window becomes cumulative — by default it frames "all rows from the start of the partition up to the current row." That gives a running total:
SELECT customer_id, created_at,
hours * price_per_hour AS spend,
SUM(hours * price_per_hour)
OVER (PARTITION BY customer_id ORDER BY created_at) AS running_total
FROM rentals
ORDER BY customer_id, created_at;For customer 3 the running_total climbs across the dates: $32.00 (Jun 2, H100 = 8×4) → $37.00 (Jun 5, +A100 2×2.50) → $57.00 (Jun 7, +H100 5×4). Each row shows the cumulative spend through that date — the detail row plus everything before it in the partition.
Ranking: ROW_NUMBER, RANK, DENSE_RANK
These number rows within each partition by the ORDER BY. Rank each customer's rentals from most to least expensive:
SELECT customer_id, rental_id,
hours * price_per_hour AS spend,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY hours*price_per_hour DESC) AS rn,
RANK() OVER (PARTITION BY customer_id ORDER BY hours*price_per_hour DESC) AS rnk
FROM rentals
ORDER BY customer_id, rn;ROW_NUMBER gives a strict 1,2,3 with no ties (arbitrary tie-break). RANK gives ties the same number then skips (1,1,3); DENSE_RANK wouldn't skip (1,1,2). The classic "top-N per group" — e.g. each customer's single biggest rental — is ROW_NUMBER() ... = 1 wrapped in a subquery or CTE, since you can't filter a window function in WHERE directly.
Looking at neighbors: LAG and LEAD
LAG reaches to the previous row, LEAD to the next — perfect for "change since last time." Days between a customer's consecutive rentals:
SELECT customer_id, created_at,
LAG(created_at) OVER (PARTITION BY customer_id ORDER BY created_at) AS prev_date,
created_at - LAG(created_at)
OVER (PARTITION BY customer_id ORDER BY created_at) AS days_since_prev
FROM rentals
ORDER BY customer_id, created_at;The first rental in each partition has prev_date = NULL (nothing before it). Customer 1: Jun 1 → Jun 3 gives days_since_prev = 2. Customer 3: Jun 2 → Jun 5 → Jun 7 gives 3 then 2. This LAG-diff pattern powers retention, session gaps, and "did the number move" reports everywhere.
CTEs & subqueries
As queries grow, nesting subqueries inside subqueries becomes unreadable. A Common Table Expression (WITH) lets you name a query and reference it like a temporary table — reading top to bottom instead of inside out. Compare a subquery and a CTE doing the same thing:
-- subquery: read inside-out
SELECT customer_id, total
FROM (SELECT customer_id, SUM(hours*price_per_hour) AS total
FROM rentals GROUP BY customer_id) t
WHERE total > 30;
-- same logic as a CTE: read top-to-bottom
WITH spend AS (
SELECT customer_id, SUM(hours*price_per_hour) AS total
FROM rentals
GROUP BY customer_id
)
SELECT customer_id, total
FROM spend
WHERE total > 30;Both return customers 1 ($41) and 3 ($50). Identical result — but the CTE version reads in the order the work happens and gives the intermediate a name (spend) you can reuse. For anything beyond a trivial subquery, CTEs are how you keep complex SQL maintainable: each WITH block is one named, testable step you can chain.
You can chain multiple CTEs, each building on the last — this is the readable way to express a multi-stage transformation:
WITH per_customer AS (
SELECT customer_id, SUM(hours*price_per_hour) AS total
FROM rentals GROUP BY customer_id
),
ranked AS (
SELECT *, RANK() OVER (ORDER BY total DESC) AS spend_rank
FROM per_customer
)
SELECT * FROM ranked WHERE spend_rank <= 2;CTEs can also be recursive — a query that references itself to walk a hierarchy or generate a series. Here's the canonical "generate numbers 1–5" to show the mechanics (a base case UNION ALL a step that stops at a condition):
WITH RECURSIVE counter(n) AS (
SELECT 1 -- base case
UNION ALL
SELECT n + 1 FROM counter -- recursive step
WHERE n < 5 -- stop condition
)
SELECT n FROM counter;Five rows: 1, 2, 3, 4, 5. The base case seeds the result; the recursive part runs against the rows just produced until the WHERE stops it. The same shape walks org charts, category trees, and "all the parts in this assembly" — anything that points at itself. Always include a stop condition, or it runs forever.
Set operations
These stack the results of two queries (which must have matching columns) vertically: UNION / UNION ALL combine, INTERSECT keeps rows in both, EXCEPT keeps rows in the first but not the second.
-- GPUs rented in the first half of June, OR by pro customers
SELECT gpu_model FROM rentals WHERE created_at < '2026-06-04'
UNION
SELECT r.gpu_model FROM rentals r
JOIN customers c ON c.customer_id = r.customer_id
WHERE c.plan = 'pro';UNION removes duplicate rows; to do that the database must sort or hash every row to compare them — real work on large inputs. UNION ALL just concatenates, no dedup, and is much cheaper. The above with UNION returns just A100, H100 (deduplicated). If you know there are no duplicates, or you want them kept, always use UNION ALL — reaching for plain UNION out of habit is a silent performance tax on big queries.
EXCEPT is a clean way to ask "what's missing." GPU models that exist in rentals but were never rented by a free customer:
SELECT DISTINCT gpu_model FROM rentals
EXCEPT
SELECT r.gpu_model FROM rentals r
JOIN customers c ON c.customer_id = r.customer_id
WHERE c.plan = 'free';H100 only — A100 was rented by Lin (free), so it's subtracted out; H100 never was. INTERSECT would instead return only models appearing in both sets. These are often more readable than the equivalent NOT IN / IN subqueries for set-style questions.
The optimization mindset
Chapter 04 showed how the planner turns SQL into physical work (index scans, hash joins, sorts). You don't tune by guessing — you tune by giving the planner less work and better access paths. A handful of habits cover most of it:
| Do | Don't | Why |
|---|---|---|
Filter early — push WHERE as close to the scan as possible | Pull everything, then filter in the app or a late HAVING | Fewer rows enter every later step (join, sort, aggregate) |
| Select only the columns you need | SELECT * in production queries | Less data to read, transfer, and sort; lets covering indexes work |
Keep predicates sargable: WHERE created_at >= '2026-06-01' | WHERE date_trunc('month', created_at) = '2026-06-01' | A function on the column hides it from the index → full scan |
| Compare a column to a constant | Wrap the indexed column in UPPER(), CAST(), math, etc. | Same reason — the index is on the raw column, not the transformed value |
| Aggregate to the right grain before joining | Join one-to-many then SUM | Avoids the fan-out inflation from the joins section |
Read the plan with EXPLAIN (ANALYZE) | Assume why it's slow | The plan shows the real access paths, row estimates, and time |
"Sargable" (Search ARGument ABLE) means a predicate the engine can satisfy with an index range. The instant you wrap an indexed column in a function — WHERE UPPER(name) = 'ADA' — the index on name can't be used and you're back to scanning every row. Rewrite to leave the column bare and transform the constant instead.
-- see the plan: is it an Index Scan or a Seq Scan? how many rows?
EXPLAIN ANALYZE
SELECT customer_id, SUM(hours*price_per_hour)
FROM rentals
WHERE created_at >= '2026-06-02'
GROUP BY customer_id;Read it inside-out: the indented children run first and feed their parents. Watch for a Seq Scan on a big table where you expected an index, and for a large gap between the planner's estimated rows and the actual rows — that gap usually means stale statistics (run ANALYZE) and is a top cause of a query "suddenly" going slow. Our table is tiny, so the planner may pick a seq scan here anyway; the habit of looking is what transfers to the big tables.
✓ Check yourself
- Why does an INNER JOIN drop Priya but a LEFT JOIN keep her — and how would you list only customers with no rentals?
- What goes wrong if you
SUMa per-customer value after joining to a one-to-many rentals table, and how do you fix it? - In one sentence: what's the difference between
GROUP BYand a window function? - Why is
WHERE date_trunc('month', created_at) = '2026-06-01'slower than a plain range filter?
Exercise — Running total of spend per customer over time
Write one query that returns, for every rental, the customer, the rental date, that rental's spend, and the customer's running total of spend up to and including that date — ordered so each customer's rentals appear chronologically. Use a window function (no self-join).
SELECT customer_id,
created_at,
hours * price_per_hour AS spend,
SUM(hours * price_per_hour)
OVER (PARTITION BY customer_id ORDER BY created_at) AS running_total
FROM rentals
ORDER BY customer_id, created_at;Expected shape: six rows (one per rental, nothing collapsed), four columns. Within each customer the running_total only ever increases as dates advance. For customer 1 you'll see $25.00 then $41.00; for customer 3, $32.00 → $37.00 → $57.00. If you wrote a GROUP BY and lost the per-rental detail rows, that's the tell that this is a window problem, not an aggregation one — the whole point is keeping every row while annotating it with a cumulative value.
Next
SQL expresses what you want from data at rest; now we pick up the language that moves, reshapes, and orchestrates data around it. → Python for Data Engineering