Skip to main content
Learning Center
Python Programming

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 JOIN keeps every row from the left table, filling the right-hand columns with NULL where 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 not NULL. After a left join those differ, and the second is usually what you want.
  • SUM of no rows is NULL, not 0. Wrap it in COALESCE(SUM(...), 0) when a report needs a number.
  • GROUP BY promises no particular order. Add ORDER BY whenever 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

agencies
agency_codename
GSAGeneral Services Administration
VAVeterans 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

notices
notice_idagency_codeamount
A-1GSA300000
A-2GSA100000

Both notices point to GSA. There is no VA notice in this fixture.

Inner join · before grouping

agencies + notices

Keep matching key pairs

Inner joined rows (key and notice ID)
agency_codenotice_id
GSAA-1
GSAA-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

Left joined rows (key and notice ID)
agency_codenotice_id
GSAA-1
GSAA-2
VANULL

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)

inner · actual query output
namecount
General Services Administration2

Only GSA remains. The query orders by agency name.

Left result · grouped

Left joined rows

GROUP BY a.name; COUNT(n.notice_id)

outer · actual query output
namecount
General Services Administration2
Veterans Affairs0

VA contributes zero non-null notice IDs. COUNT(*) would instead count its one placeholder row.

Join matching can repeat a left row once per matching right row. A left join preserves unmatched left rows, not unmatched right rows. These joined-row displays show only key and ID columns; their ordering is chosen for explanation. The grouped outputs match the worked queries and their ORDER BY. The worked script declares a foreign key but does not issue PRAGMA foreign_keys = ON; this picture is not evidence of enforced referential integrity.

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

  1. What does an inner join leave out that a left join keeps?
  2. Why prefer COUNT(column) over COUNT(*) after a left join?
  3. What does SUM return for a group with no matching rows, and how do you fix it?
  4. What is the difference between WHERE and HAVING?
Answers
  1. 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.
  2. The left join produces a row with all right-hand columns NULL when 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.
  3. NULL, not zero. Wrap it in COALESCE(SUM(...), 0) when the report needs a number.
  4. WHERE filters individual rows before grouping; HAVING filters 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.

learning.goultergroup.com

The interactive parts of this page have not loaded. Reading and links still work; reload the page to try again.