Module 9: SQL and Persistent Data
Normalised Tables, Joins, and Grouped Answers
Splitting repeated data into its own table, joining it back on a shared key, and knowing which rows an inner join quietly leaves out of your report.
Lesson 29 of 46 in the recommended order · About 25 min (estimate)
On this page
Outcome
By the end of this lesson you can move repeated data into its own table, join it back on a shared key, and say which rows an inner join removes from a report before anyone reads it.
Why it matters
Storing the full agency name on every notice looks harmless with three notices. With four thousand it means the same text repeated four thousand times, three different spellings of it, and no single place to correct one.
Splitting it out fixes all three at once, and introduces the operation that puts it back together. Joins are also where reports acquire their most invisible defect: an inner join silently drops anything without a match on both sides, so "agencies and their notice counts" quietly becomes "agencies that published at least one notice", which is a different report with the same title.
Concept
Normalisation, at the level this project needs, means each fact lives in one place. The agency's name belongs on an agency row; each notice carries only the agency's short code.
A foreign key is a column holding another table's key. Declaring it, agency_code TEXT NOT NULL REFERENCES agencies(agency_code), documents the relationship and lets the database refuse a notice pointing at an agency that does not exist. SQLite only enforces that when foreign-key support is switched on for the connection, with PRAGMA foreign_keys = ON, which is worth doing in any project that relies on it.
A join matches rows from two tables on a condition:
SELECT a.name, n.notice_id
FROM agencies a
JOIN notices n ON n.agency_code = a.agency_code
The short aliases, a and n, keep the rest of the query readable, and qualifying each column with its table makes it obvious where every value comes from.
Two kinds matter here:
JOIN(an inner join) keeps only rows that match on both sides. An agency with no notices disappears; a notice with an unrecognised agency code disappears.LEFT JOINkeeps every row from the left table, filling the right-hand columns withNULLwhere there is no match. The agency with no notices survives, with nothing attached.
Choosing between them is a reporting decision, not a technical one. "Which agencies published work we can bid on" wants an inner join. "How much did each agency publish this month, including the ones that published nothing" needs a left join, because zero is the answer you are looking for.
GROUP BY collapses matched rows into one row per group, and the aggregate functions, COUNT, SUM, AVG, MIN, MAX, summarise each group. Three details are worth memorising:
COUNT(*)counts rows;COUNT(column)counts rows where that column is notNULL. After a left join those differ, and the second is usually what you want.SUMof no rows isNULL, not0. Wrap it inCOALESCE(SUM(...), 0)when a report needs a number.GROUP BYpromises no particular order. AddORDER BYwhenever the order matters, which for a report is always.
WHERE filters rows before grouping; HAVING filters groups after. "Only Oregon notices" is a WHERE; "only agencies with more than two notices" is a HAVING.
Read the code
import sqlite3
connection = sqlite3.connect(":memory:")
connection.executescript(
"""
CREATE TABLE agencies (agency_code TEXT PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE notices (
notice_id TEXT PRIMARY KEY,
agency_code TEXT NOT NULL REFERENCES agencies(agency_code),
amount INTEGER
);
INSERT INTO agencies VALUES ('GSA', 'General Services Administration');
INSERT INTO agencies VALUES ('VA', 'Veterans Affairs');
INSERT INTO notices VALUES ('A-1', 'GSA', 300000);
INSERT INTO notices VALUES ('A-2', 'GSA', 100000);
"""
)
inner = connection.execute(
"SELECT a.name, COUNT(n.notice_id) FROM agencies a"
" JOIN notices n ON n.agency_code = a.agency_code"
" GROUP BY a.name ORDER BY a.name"
).fetchall()
outer = connection.execute(
"SELECT a.name, COUNT(n.notice_id) FROM agencies a"
" LEFT JOIN notices n ON n.agency_code = a.agency_code"
" GROUP BY a.name ORDER BY a.name"
).fetchall()
print(inner)
print(outer)
Two tables, two agencies, two notices, both belonging to the same agency. executescript runs several statements at once, which is convenient for setting up a schema.
The two queries differ by one word. Everything else, the grouping, the counting, the ordering, is identical.
COUNT(n.notice_id) rather than COUNT(*) is deliberate. In the left join, the Veterans Affairs row is produced with every notice column NULL, so COUNT(*) would count that one phantom row and report 1. Counting these non-null notice IDs ignores the unmatched agency’s placeholder row. To count all matches in general, choose a right-hand column guaranteed non-null on every matched row.
Inspect keys, joined rows and grouped counts
Match keys first; count the resulting rows
Join key: notices.agency_code = agencies.agency_code. GSA matches two notices; VA matches none.
The joined-row tables expose the intermediate relationship. The two grouped tables are the actual worked query outputs. No Venn areas stand in for row counts.
Left source · agencies
Stored agency rows
One row per agency_code
| agency_ | name |
|---|---|
| GSA | General Services Administration |
| VA | Veterans Affairs |
These are the left-hand rows. VA must survive when the report includes agencies with no notices.
Right source · notices
Stored notice rows
Each agency_code refers to an agency
| notice_ | agency_ | amount |
|---|---|---|
| A-1 | GSA | 300000 |
| A-2 | GSA | 100000 |
Both notices point to GSA. There is no VA notice in this fixture.
Inner join · before grouping
agencies + notices
Keep matching key pairs
| agency_ | notice_ |
|---|---|
| GSA | A-1 |
| GSA | A-2 |
One GSA agency row participates in two matches, so two joined rows remain. VA has no matched pair and is omitted.
Left join · before grouping
agencies + notices
Keep matches and unmatched left rows
| agency_ | notice_ |
|---|---|
| GSA | A-1 |
| GSA | A-2 |
| VA | NULL |
NULL denotes a SQL null, not the text "NULL". The VA placeholder has all notice-side columns null; it is not a stored notice.
Inner result · grouped
Inner joined rows
GROUP BY a.name; COUNT(n.notice_id)
| name | count |
|---|---|
| General Services Administration | 2 |
Only GSA remains. The query orders by agency name.
Left result · grouped
Left joined rows
GROUP BY a.name; COUNT(n.notice_id)
| name | count |
|---|---|
| General Services Administration | 2 |
| Veterans Affairs | 0 |
VA contributes zero non-null notice IDs. COUNT(*) would instead count its one placeholder row.
Predict the output
Predict both printed lines.
Check your prediction
[('General Services Administration', 2)]
[('General Services Administration', 2), ('Veterans Affairs', 0)]
The inner join returns one row. Veterans Affairs exists, has a name, and is simply absent, with nothing in the output hinting that a second agency was ever considered.
Imagine both results printed under the heading "notices published by agency". The first is not wrong exactly; it answers a narrower question than its heading claims, and a reader has no way to notice.
Modify the code
In the outer query, change COUNT(n.notice_id) to COUNT(*) and predict the second printed line.
What changes, and why
[('General Services Administration', 2), ('Veterans Affairs', 1)]
Veterans Affairs now reports one notice, and it has none.
The left join produced a row for it with every notice column set to NULL. COUNT(*) counts rows, and that is a row, so it counts. COUNT(n.notice_id) counts non-null ID values. Every actual notice ID in this fixture is non-null, while the unmatched placeholder has a null ID, so the count correctly reports zero for Veterans Affairs.
This is a small, very common defect that produces a plausible number. Any report showing "1" for something that should be "0", especially after a left join, is worth checking for exactly this.
Debug the bug
An assistant was asked for the total value published by each agency, including agencies with nothing. It produced this.
rows = connection.execute(
"SELECT a.name, SUM(n.amount) AS total FROM agencies a"
" LEFT JOIN notices n ON n.agency_code = a.agency_code"
" WHERE n.amount > 0"
" GROUP BY a.name"
).fetchall()
for name, total in rows:
print(f"{name}: {total:,}")
What's actually wrong
Two faults, and the first one undoes the entire purpose of the query.
WHERE n.amount > 0 runs after the join and before the grouping. For the Veterans Affairs row, n.amount is NULL, and NULL > 0 is unknown, so WHERE discards it. The left join was written specifically to keep that agency, and the WHERE clause throws it away again, turning the query back into an inner join with extra words.
The fix is to move the condition into the join, where it filters what is matched rather than what survives:
LEFT JOIN notices n ON n.agency_code = a.agency_code AND n.amount > 0
The second fault appears the moment the first is fixed. With no matching notices, SUM returns NULL, not 0, and the f-string's :, format raises TypeError: unsupported format string passed to NoneType.__format__. Wrap it: COALESCE(SUM(n.amount), 0) AS total.
The general rule worth carrying: after a LEFT JOIN, a WHERE condition that rejects the null-extended rows, such as n.amount > 0, removes unmatched left rows. A condition such as n.notice_id IS NULL instead keeps unmatched rows; the effect depends on the predicate. If the condition belongs to the match, put it in the ON clause.
Try it yourself
Two normalised tables and one agency that has published nothing. Report each agency that has published at least one notice, with its count and total value.
Loading this exercise…
Practical challenge (optional)
Optional: rerun your query as a LEFT JOIN with COALESCE(SUM(n.amount), 0), and put the two results side by side. Then write one sentence for each, stating the question that result correctly answers. Being able to name the question a report answers, rather than the one its title implies, is the most useful habit in this module.
Sign in to track your progress on this exercise.
AI collaboration
Checkpoint
- What does an inner join leave out that a left join keeps?
- Why prefer
COUNT(column)overCOUNT(*)after a left join? - What does
SUMreturn for a group with no matching rows, and how do you fix it? - What is the difference between
WHEREandHAVING?
Answers
- A left join keeps unmatched rows from the left table only. With agencies on the left, an agency with no notices survives; a notice with an unrecognised agency code on the right is not preserved by either this inner join or this left join.
- The left join produces a row with all right-hand columns
NULLwhen there is no match.COUNT(*)counts that placeholder row as one.COUNT(column)counts non-null values, so choose a right-hand column guaranteed non-null on each real match when you want the number of matches. NULL, not zero. Wrap it inCOALESCE(SUM(...), 0)when the report needs a number.WHEREfilters individual rows before grouping;HAVINGfilters whole groups after the aggregates have been computed.
Sign in to track your progress on this exercise.
Summary and next step
Repeated facts belong in their own table; foreign keys enforce the link when enforcement is enabled. An inner join omits unmatched rows, while a left join preserves unmatched left rows. Count a non-null match identifier and use COALESCE where zero is intended. A WHERE predicate that rejects null-extended rows can remove the rows a left join preserved. Next: how a value gets into a query safely, and how a schema changes without losing what is already stored.