Without notes, state yesterday’s main idea and one unresolved question.
Joins and aggregation
SQL and PostgreSQL
Objective
Answer questions spanning related tables.
A relational schema resembles a disciplined wiring and labeling plan: constraints prevent invalid connections and keys define relationships.
- INNER and LEFT JOIN
- COUNT and GROUP BY
- aggregate interpretation
Why this matters
On Day 43 you split one spreadsheet into two tables so no fact was stored twice. That was the right call, and it left you with a problem: the questions people actually ask span both tables. "Which equipment has never been serviced?" touches equipment and maintenance records at once.
Today you learn to put the tables back together for the length of one query, and to summarise many rows into one number. By the end you can produce the report your API's dashboard will need — every piece of equipment, its service count, and its last service date — including the equipment with no records at all, which is the part almost everyone gets wrong.
Joining two tables
A join matches rows from one table with rows from another, using a condition that says which rows belong together. That condition is almost always "the foreign key equals the primary key".
SELECT e.name, m.performed_on, m.notes
FROM maintenance_records m
INNER JOIN equipment e ON e.id = m.equipment_id
ORDER BY m.performed_on;
name | performed_on | notes
------------------+--------------+-----------------------------------
Feed Pump 3 | 2024-02-11 | Replaced mechanical seal
Chiller 1 | 2024-05-19 | Coil clean and refrigerant top-up
Feed Pump 3 | 2024-09-03 | Checked shaft alignment
Air Compressor A | 2025-01-08 | Rebuilt compressor valve plate
Feed Pump 3 | 2025-03-22 | Bearing replacement
(5 rows)
Two pieces of syntax to read carefully. maintenance_records m gives the table the short alias
m, so m.notes means "the notes column of that table". Aliases are not decoration: both tables
have an id column, and without a prefix the database cannot tell which you mean. ON e.id = m.equipment_id is the join condition — the rule for pairing rows.
Notice Feed Pump 3 appears three times. The join produces one row per pair, so a piece of
equipment with three records contributes three rows. The equipment's name is repeated in the
result, but it is still stored only once on disk.
A join is a temporary jumper, not a rewire
Your tables are separate boards with a labelled interconnect: equipment_id on one, id on the
other. A join is clipping a test lead between those two labelled points for the duration of one
measurement, reading the combined signal, and removing the lead. Nothing on either board is
altered, and the next query may clip somewhere completely different. That is why splitting data
across tables costs you nothing permanent.
INNER JOIN and LEFT JOIN
The join above was an INNER JOIN: it returns only rows that found a match on both sides. Watch
what that discards.
SELECT e.name, m.id AS record_id, m.performed_on
FROM equipment e
LEFT JOIN maintenance_records m ON m.equipment_id = e.id
ORDER BY e.name, m.performed_on;
name | record_id | performed_on
------------------+-----------+--------------
Air Compressor A | 4 | 2025-01-08
Chiller 1 | 3 | 2024-05-19
Conveyor B | |
Feed Pump 3 | 1 | 2024-02-11
Feed Pump 3 | 2 | 2024-09-03
Feed Pump 3 | 5 | 2025-03-22
Feed Pump 4 | |
(7 rows)
A LEFT JOIN keeps every row from the left table — the one named in FROM — whether or not it
matched. Where there was no match, the right table's columns come back as NULL, which psql
prints as an empty space. Conveyor B and Feed Pump 4 are here with blank record columns. An
INNER JOIN would have dropped them entirely, and your report would silently claim those two
machines do not exist.
That is the whole decision. Ask yourself: do I want rows from the left table that have no
partner? If the answer is yes — never-serviced equipment, customers with no orders, users with no
posts — you need LEFT JOIN. If the answer is no, INNER JOIN says so more clearly.
Two lists on a clipboard
An inner join is stapling the machine list to the visit log and keeping only the machines that appear in both. A left join keeps every machine on the list and leaves the visit column blank for the ones nobody has been out to. If you are asked "which machines have we neglected?", stapling the wrong way makes the answer invisible.
COUNT and GROUP BY
An aggregate function collapses many rows into one value: count, sum, avg, min, max.
On its own, an aggregate reduces the whole result to a single row.
GROUP BY splits the rows into groups first, then applies the aggregate to each group separately,
giving one output row per group.
SELECT e.name, count(m.id) AS record_count, max(m.performed_on) AS last_service
FROM equipment e
LEFT JOIN maintenance_records m ON m.equipment_id = e.id
GROUP BY e.id, e.name
ORDER BY e.name;
name | record_count | last_service
------------------+--------------+--------------
Air Compressor A | 1 | 2025-01-08
Chiller 1 | 1 | 2024-05-19
Conveyor B | 0 |
Feed Pump 3 | 3 | 2025-03-22
Feed Pump 4 | 0 |
(5 rows)
That is the deliverable report. Group by e.id as well as e.name, not just the name: id is
what actually identifies the equipment, so two machines that happen to share a name still get
their own row.
The rule that trips beginners: every column in the SELECT list must either appear in GROUP BY
or be wrapped in an aggregate. Otherwise the database has many values and one output slot, and it
tells you so — ERROR: column "e.serial_number" must appear in the GROUP BY clause or be used in an aggregate function.
HAVING filters groups after aggregation, where WHERE filters rows before it:
SELECT e.name, sum(m.hours_spent) AS total_hours, round(avg(m.hours_spent), 2) AS avg_hours
FROM equipment e
JOIN maintenance_records m ON m.equipment_id = e.id
GROUP BY e.id, e.name
HAVING sum(m.hours_spent) > 2
ORDER BY total_hours DESC;
name | total_hours | avg_hours
------------------+-------------+-----------
Feed Pump 3 | 4.25 | 1.42
Air Compressor A | 4.00 | 4.00
Chiller 1 | 3.25 | 3.25
(3 rows)
(JOIN with no qualifier means INNER JOIN. Writing INNER is clearer.)
Reading an aggregate correctly
An aggregate is a number, which makes it look more trustworthy than it is. Two traps do most of the damage.
count(*) counts rows; count(column) counts non-null values. After a LEFT JOIN, an
unmatched piece of equipment still produces one row, so count(*) reports 1 for a machine that
has never been serviced:
SELECT e.name, count(*) AS wrong_count, count(m.id) AS right_count
FROM equipment e
LEFT JOIN maintenance_records m ON m.equipment_id = e.id
GROUP BY e.id, e.name
ORDER BY e.name;
name | wrong_count | right_count
------------------+-------------+-------------
Air Compressor A | 1 | 1
Chiller 1 | 1 | 1
Conveyor B | 1 | 0
Feed Pump 3 | 3 | 3
Feed Pump 4 | 1 | 0
(5 rows)
Three columns agree and two do not, which is exactly how this bug survives review. After a LEFT JOIN, always count a column from the right table.
Aggregates other than count ignore NULL and return NULL for an empty group. sum over no
rows is not 0; it is "nothing to add up". If your API must send a number, say so explicitly with
coalesce, which returns its first non-null argument:
SELECT e.name, coalesce(sum(m.hours_spent), 0) AS total_hours
FROM equipment e LEFT JOIN maintenance_records m ON m.equipment_id = e.id
GROUP BY e.id, e.name ORDER BY e.name;
name | total_hours
------------------+-------------
Air Compressor A | 4.00
Chiller 1 | 3.25
Conveyor B | 0
Feed Pump 3 | 4.25
Feed Pump 4 | 0
(5 rows)
Walkthrough: build the report in four steps
Never write a join and an aggregate in one go. Add one clause and look.
- Join and look at the raw pairs. Run the
LEFT JOINfrom earlier with no grouping. Seven rows. Count the blanks: two. - Switch to
INNER JOINand rerun. Five rows, and the two blanks are gone. You have just seen, on your own data, what the join type decides. - Go back to
LEFT JOINand addGROUP BY e.id, e.namewithcount(m.id). Five rows again — one per machine — with0for the two. - Add
max(m.performed_on) AS last_service. Blank for the never-serviced pair. Blank is the honest answer: there is no last service date, and1970-01-01or0would be a lie.
Checkpoint
In step 3, swap count(m.id) for count(*) and rerun. Two zeros become ones. Say out loud why,
then put it back. If you can explain that difference, you understand what a left join actually
returns.
How to use AI today
Today's mode is tutor: ask it to explain the concept, give a small example, then let you attempt it — not to hand you the finished query. A join you did not reason through is one you cannot fix when the counts look wrong.
Tutor mode — after you have run steps 1 and 2 yourself
"Explain why LEFT JOIN is needed for equipment with zero maintenance records." Predict the row counts for both join types before you read the answer, then check your prediction against the output you already have on screen.
Your turn
Produce db/reports.sql: join queries that correctly handle missing related rows.
- Reload
db/seed.sqlso your data matches this lesson. - Write an
INNER JOINlisting every maintenance record with its equipment name, oldest first. Record the row count. - Change it to a
LEFT JOINfromequipment. Record the new count and name the rows that appeared. - Write the report: equipment name,
count(m.id)asrecord_count,max(m.performed_on)aslast_service, grouped bye.id, e.name, ordered by name. All five machines must appear. - Deliberately break it — remove
e.idfrom theGROUP BY, or selecte.statuswithout grouping it — and copy the exact error into a comment. Then fix it. - Add a query using
sumandcoalesceso equipment with no records reports0hours, not a blank. - Add one using
HAVINGto show only equipment with more than one maintenance record. - Above each query, write the English question it answers and the row count you observed.
You are done when
db/reports.sql runs clean, your main report shows all five machines with 0 and a blank date
for the two never serviced, and you can say which single word you would change to make those two
disappear.
Common pitfalls
- Reaching for
INNER JOINby habit. It is the default in most people's fingers and it silently deletes exactly the rows a "which of these has none?" question is about. count(*)after aLEFT JOIN. Reports1where the answer is0. Count a right-table column.- Putting a right-table condition in
WHEREinstead ofON. `LEFT JOIN ... WHERE m.performed_on'2025-01-01'
throws away the unmatched rows again, because theirNULLfails the test. Move that condition into theON` clause. - Expecting
sumto be0for an empty group. It isNULL. Wrap it incoalescewhen the API needs a number.
Verify it yourself
Open today's reference, the PostgreSQL tutorial, and read its section on joins between tables.
- The tutorial covers
RIGHT JOINandFULL OUTER JOIN. Write down, in your own words, how aRIGHT JOINdiffers from aLEFT JOIN, and why you can always rewrite one as the other. - This lesson claimed a
LEFT JOINfills unmatched right-hand columns withNULL. Find the documentation's own statement and record whether it agrees.
Add both as comments in db/reports.sql. Tomorrow you make multi-step changes safe.
The hour
- 0–5 min Recall
- 5–20 min Learn
Read only the listed concept notes and official reference sections needed today.
- 20–48 min Build
List equipment with maintenance count and most recent maintenance date. Include equipment with zero records.
- 48–55 min Explain and verify
Run the result, inspect evidence, and explain the data/control flow in your own words.
- 55–60 min Quiz and commit
Complete the quiz, record one lesson, and commit the verified change when applicable.
What to hand in
Join queries that correctly handle missing related rows.
Working with AI today
Ask for explanations, analogies, questions, and hints. Do not request a complete finished solution first.
Explain why LEFT JOIN is needed for equipment with zero maintenance records.
References
End-of-day quiz
Explain-back gate
Pass the quiz above to unlock completion.
Quiz + explain-back checks required.