Vocabulary for this course

The terms the chapters assume, in plain words. You can open this from any chapter.

alias
A short second name given to a table or column in a query, so the rest of the query can refer to it briefly.
Cartesian product
Every row of one table paired with every row of another, which happens when a join has no matching rule.
column
One named field in a table, with the same kind of value in every row, such as a title or a year.
constraint
A rule a table enforces on its data, such as requiring a value or making one column unique.
distinct
A word in a query that removes repeated rows from the result, so each one appears once.
foreign key
A column that refers to the primary key of another table, so each row can be tied to one row there.
index
A stored lookup that helps the database find rows quickly without reading the whole table.
inner join
The default way of combining two tables: only pairs of rows present on both sides appear in the result.
join
A query operation that combines rows from two tables by matching values they share, usually a shared identifier.
junction table
A bridge between two tables that lets a row on either side pair with many on the other; each of its rows holds one pair.
left join
A combination that keeps every row of the first table named, filling in nulls where the second has no match.
many-to-many
A relationship where one row on each side can pair with many rows on the other, usually held in a junction table.
null
A marker for a value that is missing or unknown, which is not the same as zero or an empty string.
outer join
A join that keeps rows from one or both tables even when no row on the other side matches.
primary key
The column or columns that together identify each row uniquely, and which can never be missing.
query
A request written in SQL that asks the database for an answer, usually a table of rows.
row
One record in a table, with one value for each column that table defines.
schema
The stored plan of a database: its tables, the columns in each, and the rules those columns must follow.
table
A named set of rows that all share the same columns, such as one holding every book in a library.

SQL from the ground up

A small report

Everything at once: three tables joined, grouped and labelled — a report written the way reports are, one piece at a time and then combined.

Part 12 of 12

Advanced18 min readBack to Courses

The question first

The report to build: for every author, how many books they wrote, how many copies those books sold, and a one-word label to go with the number — busiest first, and authors with no sales included rather than lost.

That sentence contains the whole plan. Each author: one row per author, so the grouping is by author. How many books: a count from the books table. Copies: a sum, which lives in a third table that has to be joined. A label: CASE. Busiest first: ORDER BY on the total. Included rather than lost: a LEFT JOIN, because an author with no books has no sales to match.

Building it in pieces

The habit that makes a report like this easy is the same one this course has used throughout: write the list of pieces first, then assemble them. A CTE for the counts per author keeps the final SELECT short enough to read.

Watch the joins do their work: books to authors gives each book its author, sales to books gives each sale its title, and the grouping folds all of it back into one row per author. Every piece answers one question.

Run a CTE on its own before attaching it to anything. The clause is a complete query in parentheses, so it can be executed by itself to see the rows it produces; a summary at the wrong grain is much easier to spot in isolation than in the finished report.

Decide the grain first

Before writing a join, decide what one row of the answer means. "One row per author" is a grain; so is "one row per book" and "one row per author per year". Every other decision follows from it: the grouping, the counts, and which table the join must keep whole.

The grain is also the test of a report. If the answer has two rows for the same author, either the grain is finer than you said or a join multiplied the rows. Reading the first few lines is enough to catch it.

Write the grain as a sentence and keep it next to the query. It is the one comment that stays true when the query changes.

Fan-out, and the count that follows

Joining a parent to a child repeats the parent once per child. Authors to books repeats an author per book; books to sales repeats a book per sale. A count after both joins counts the deepest rows, not the authors — which is why a report that says "books" can quietly report sales.

COUNT(DISTINCT b.id) counts each parent once, however many children it has. SUM has the same problem from the other side: if a book row is repeated by a join, its price is added once per repetition. Aggregate the child table before joining it, or use a subquery per metric, when the fan-out is more than one level.

The safest layout is one CTE per grain: count books per author in one, sum sales per book in another, then join the two summaries on the key. Each summary is already at the grain the final SELECT needs.

Check one number a second way

The quickest way to trust a report is to compute one of its numbers a second way. Sum the copies for a single author by hand from the sales table, then compare that with the row the report gives for them. If the two disagree, the join is the suspect: a missing ON clause, a filter in the wrong place, or a fan-out that counted a child twice.

Pick the author with no sales for the second check. That row is where outer joins and COALESCE fail quietly, and a zero that should have been a blank — or the other way round — is easiest to spot on a row with nothing in it.

The report, top to bottom

WITH totals AS (
  SELECT a.id, a.name, COUNT(DISTINCT b.id) AS books, COALESCE(SUM(s.copies), 0) AS copies
  FROM authors a
  LEFT JOIN books b ON b.author_id = a.id
  LEFT JOIN sales s ON s.book_id = b.id
  GROUP BY a.id, a.name
)
SELECT name,
       books,
       copies,
       CASE
         WHEN copies > 100 THEN 'best seller'
         WHEN copies > 0 THEN 'on the shelf'
         ELSE 'no sales yet'
       END AS standing
FROM totals
ORDER BY copies DESC, name;

Three report mistakes: counting parent rows after a one-to-many join, where COUNT(DISTINCT id) is the fix; filtering the right table in WHERE after a LEFT JOIN, which drops the very rows the outer join was keeping; and summing a nullable column without COALESCE, so an author with no sales shows a blank instead of zero.

Try these next

  • Add a metric for the average copies per book and keep the author with no sales.
  • Change the grain to one row per book and rewrite the grouping.
  • Filter to books published after 1970 without dropping authors who have none — put the condition in the ON clause.

The author report

Write the report: every author with their number of books, their total copies sold, and a label — the most sold first, authors with no sales included.

Live Preview

A customer report, one row per customer

Run it, then lower the top threshold to 40 and add the average revenue per order as a fifth column.

Live Preview

A report is a query you can read aloud. When one gets long, the fix is rarely clever syntax — it is naming the pieces with WITH and keeping every step to one question.

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

Why does the report use LEFT JOIN rather than JOIN to reach the sales table?