SQL Against a Voter Registration Database

A four-table relational schema worked end to end: reading the diagram, filtering and sorting, aggregating with GROUP BY and HAVING, inner versus left joins, the anti-join pattern for finding missing rows, three-valued logic with NULL, and changing data inside a transaction.

Subject: IT Support & Networking · 63 slides · code lesson

Open the interactive version of this deck · Homework for this lesson

What this lesson covers

The lesson, slide by slide

1. SQL Against a Voter Registration Database

Title

IT · Databases

Four tables, one mark scheme, and the queries that actually return the right rows

2. What you will be able to do

Objectives

This assignment is graded on whether a query returns the correct rows. Not on style, not on length. So every section here ends with the result set, printed out.

PostgreSQL 16 Documentation — SQL Language — the reference this deck follows

3. The Schema

Section

Section 1

4. What a database is actually for

Warm-up

One minute before any syntax.

Discussion prompt

Why does a voter registration system keep precincts in a separate table instead of writing the precinct name into every voter row?

Hint: What happens when a precinct gets renamed?

Answer:

Because the name would then be stored hundreds of times, and correcting a typo would mean correcting every copy. Store it once and point at it.

The pointer is the foreign key. Almost every join you write exists because some fact was deliberately stored in exactly one place.

PostgreSQL 16 Documentation — SQL Language — Data Definition, constraints

5. Four tables, three relationships

Concept

A voter belongs to one precinct. A precinct has many voters. A ballot belongs to one voter and one election.

Each of those sentences is a foreign key, and each foreign key is a join you will eventually write.

PostgreSQL 16 Documentation — SQL Language — Data Definition

6. The schema

Picture it

Read this once properly and most of the assignment stops being guesswork.

Figure (svg): An entity relationship diagram with precincts, voters, ballots and elections tables joined by foreign keys

Every dashed line is a join condition waiting to be written.

7. The sample rows this deck uses

Concept

Five voters, three precincts, four ballots. Small enough to check every result by hand, which is exactly how you should test your own queries before submitting.

voter_idfirst_namelast_nameprecinct_idstatus
101AdaReyes1active
102BenOsei1active
103CleoNakamura2active
104DevPatel2inactive
105EsiBoateng3active
ballot_idvoter_idelection_idmethod
11017in-person
21027mail
31037mail
41018in-person

Precincts: 1 is Northside, 2 is Harbor, 3 is Westgate.

8. Match each foreign key to what it points at

Matching

Getting this wrong is the single most common reason a join returns nonsense.

Match the pairs

  • l1. voters.precinct_id
  • l2. ballots.voter_id
  • l3. ballots.election_id
  • l4. voters.voter_id
  • r1. precincts.precinct_id
  • r2. voters.voter_id
  • r3. elections.election_id
  • r4. nothing; it is the primary key

Why: A foreign key column holds a value that exists as a primary key somewhere else. The last one is the trap: voter_id in the voters table is the thing being pointed at, not a pointer.

9. Filtering and Sorting

Section

Section 2

10. The clause order you write is not the order it runs

Concept

A SELECT statement is written in one order and evaluated in another. Knowing the evaluation order explains most of SQL's apparent inconsistencies.

ISO/IEC 9075, Information technology — Database languages — SQL — the logical processing order defined by the standard

11. Written order versus evaluated order

Picture it

SELECT is written first and runs fifth.

Figure (svg): Two columns comparing the written order of SQL clauses against the logical order in which they are evaluated, with SELECT highlighted as running fifth

FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY.

Two consequences: WHERE cannot see a column alias you invented in SELECT, and ORDER BY can, because it runs last.

12. Worked example: the active voters, alphabetically

Worked example

The question in English: list every active voter, surname first, in alphabetical order.

Start from the table, not from the columns

Why: FROM runs first. Deciding what you are looking at before what you want from it matches the evaluation order.

Filter the rows with WHERE

Why: Status is a string, so the literal needs single quotes. Double quotes mean an identifier in standard SQL, which is a common first error.

Choose the columns, then sort

Why: ORDER BY runs after SELECT, so it can use anything the query produced.

SELECT last_name, first_name
FROM   voters
WHERE  status = 'active'
ORDER  BY last_name;
last_namefirst_name
BoatengEsi
NakamuraCleo
OseiBen
ReyesAda

Verify: the row count against the data

Why: Five voters, one inactive, so four rows. Patel is missing and should be. If you get five, the WHERE clause did not apply.

13. What that looks like in the client

Picture it

The row count at the bottom is the fastest check you have.

Figure (svg): A psql session running the active voters query and printing four result rows followed by a row count

Always read the row count before reading the rows.

14. Trap: double quotes around a string literal

Trap

The trap

Filtering on the status column.

Annotate

  • In standard SQL, double quotes mean a quoted identifier: a column or table name.
  • So this asks whether the status column equals a column called active, and errors that no such column exists.

Get a confusing error about a missing column

Why: The error names a column you never mentioned, which is why this one wastes so much time.

The fix

Single quotes for values, double quotes only for names that need them.

SELECT * FROM voters WHERE status = 'active';
quotemeansexample
singlea string value'active'
doublean identifier"select" as a column name
nonea number or keyword101

Remember the rule by what breaks

Why: MySQL is lenient about this and lets double quotes work as strings, which is exactly why the habit survives until it meets Postgres.

15. How many rows come back?

Prediction

Against the five sample voters.

Predict first

SELECT * FROM voters WHERE precinct_id = 2 AND status = 'active';

  • 0
  • 1
  • 2
  • 5

Correct: 1.

Why: Precinct 2 holds Nakamura and Patel. Patel is inactive, so the AND removes him and one row survives. Swapping the AND for an OR would give four rows instead, which is a good illustration of how much work that one keyword does.

16. WHERE clause, or somewhere else?

Sorting

Each condition belongs in exactly one clause.

Sort into buckets

Where does each condition go?

WHERE
only active voters; registered after 2015
HAVING
only precincts with more than one voter; only groups whose average age exceeds 40
where
The condition is about a single row and can be judged before any grouping happens. WHERE runs second, before GROUP BY.
having
The condition is about a group, and needs the aggregate to have been computed first. HAVING runs fourth, after GROUP BY.

Rule of thumb: if the condition mentions COUNT, SUM or AVG, it belongs in HAVING. Otherwise it belongs in WHERE, where it is also faster.

17. Aggregating

Section

Section 3

18. GROUP BY collapses rows into groups

Concept

An aggregate function turns many rows into one value. GROUP BY says which rows belong together while that happens.

aggregate — A function like COUNT, SUM, AVG, MIN or MAX that reduces a set of rows to a single value.

The rule that catches everyone: every column in the SELECT list must either be aggregated or named in the GROUP BY. There is no third option.

PostgreSQL 16 Documentation — SQL Language — Aggregate Functions and GROUP BY

19. Worked example: how many voters per precinct

Worked example

The question: count the registered voters in each precinct, and show the precinct name rather than its number.

Join so the name is available

Why: The count lives in voters and the name lives in precincts, so both tables are needed.

Group by the thing you want one row per

Why: One row per precinct means GROUP BY the precinct.

Count

Why: Counting the voter id rather than star makes the intent explicit and behaves correctly under a left join.

SELECT p.name, COUNT(v.voter_id) AS voter_count
FROM   precincts p
LEFT   JOIN voters v ON v.precinct_id = p.precinct_id
GROUP  BY p.name
ORDER  BY voter_count DESC;
namevoter_count
Northside2
Harbor2
Westgate1

Verify: that the counts add to the number of voters

Why: Two plus two plus one is five, and there are five voters. If a group total does not add up, a join has duplicated or dropped rows.

20. Counting star versus counting a column

Picture it

Identical here, and very different under a left join with no matches.

Figure (svg): Two bars comparing count star which counts rows including nulls against counting a column which skips nulls

COUNT of a column ignores NULLs; COUNT star does not.

A precinct with no voters gets one row from the left join, with NULL in every voter column. COUNT star would report one voter; counting the id correctly reports zero.

21. HAVING filters groups, WHERE filters rows

Concept

They look interchangeable and are not. WHERE runs before the grouping, so it cannot see a count. HAVING runs after, so it can.

SELECT p.name, COUNT(v.voter_id) AS voter_count
FROM   precincts p
JOIN   voters v ON v.precinct_id = p.precinct_id
WHERE  v.status = 'active'
GROUP  BY p.name
HAVING COUNT(v.voter_id) > 1;
clauserunsseeshere it means
WHEREbefore groupingindividual rowsonly active voters are counted at all
GROUP BYthirdthe surviving rowsone group per precinct name
HAVINGafter groupingthe aggregatesonly precincts with more than one active voter

Result: Northside alone, with two. Harbor drops to one active voter once Patel is filtered out, and Westgate had one to begin with.

PostgreSQL 16 Documentation — SQL Language — the HAVING clause

22. Find the error in this aggregate

Error analysis

A first attempt at counting voters per precinct. It looks reasonable and it will not run.

Annotate

  • county appears in the SELECT list but is neither aggregated nor grouped.
  • The GROUP BY names only p.name, so the database has no way to pick one county per group.
  • Two fixes: add p.county to the GROUP BY, or drop it from the SELECT. Adding it is usually what you meant.

The error message names the offending column, which makes this one of the friendlier SQL errors once you know what it is telling you.

23. Which aggregate query is legal?

Elimination

Counting voters and also showing a name.

Eliminate the wrong options

Which one runs?

  • A. SELECT p.name, COUNT(*) FROM ... GROUP BY p.name
  • B. SELECT p.name, v.last_name, COUNT(*) FROM ... GROUP BY p.name
  • C. SELECT COUNT(*) FROM ... GROUP BY p.name HAVING name > 1
  • D. SELECT p.name, COUNT() FROM ... WHERE COUNT() > 1 GROUP BY p.name

Survives elimination: A

Why: Every non-aggregated column in the SELECT list has to appear in the GROUP BY. That single rule explains why the second option fails, and the evaluation order explains the fourth.

24. Pattern: writing any aggregate query

Pattern

Five questions, in this order, and the query writes itself.

  1. One row per what? That answer is your GROUP BY.
  2. What number do you want per group? That is your aggregate function.
  3. Which rows should be counted at all? That is WHERE, and it goes before the grouping.
  4. Which groups should survive? That is HAVING, and it goes after.
  5. What order should they come out in? That is ORDER BY, and it can use the alias.

SQLBolt — interactive SQL lessons — lessons 6 and 7 drill exactly this

25. Check: WHERE or HAVING?

Check

Solve it on paper before you click.

Check your understanding

You want precincts where more than one voter registered after 2015. Where does each condition go?

  • A. The registration date in WHERE, the count in HAVING (correct)
  • B. Both in WHERE
  • C. Both in HAVING
  • D. The count in WHERE, the date in HAVING

Answer: A

Why: The date is a property of one row, so it is judged before grouping. The count only exists once the groups are formed, so it is judged after. That is exactly the split between the two clauses.

Why B tempts people
The count does not exist when WHERE runs, so the database has nothing to compare against.
Why C tempts people
This is legal in some databases but wrong in meaning: it would count every voter and then filter groups, instead of counting only the recent registrations.
Why D tempts people
Exactly backwards. Neither clause can do the other's job.

26. Joins

Section

Section 4

27. A join matches rows from two tables on a condition

Concept

An inner join keeps only pairs that matched. A left join keeps every row from the left table, filling the right-hand columns with NULL where nothing matched.

Which one you want depends entirely on whether an unmatched row is a row you still care about.

PostgreSQL Documentation — Table Expressions and Joined Tables — the full set of join types

28. The difference, drawn

Picture it

Two circles, one condition, two very different answers.

Figure (svg): Two Venn style diagrams comparing an inner join which keeps only the overlap against a left join which keeps all of the left circle

The question to ask: do I still want the voter who never voted?

29. Worked example: who voted in election 7

Worked example

Here an unmatched voter is not wanted, so an inner join is correct.

Name both tables and give them short aliases

Why: Aliases make the join condition readable and are required once a column name appears in both tables.

Write the join condition on the foreign key

Why: The ballots table points at voters through voter_id, so that is the condition.

Filter to the one election

Why: This is a row-level condition, so it belongs in WHERE.

SELECT v.last_name, b.method
FROM   voters v
JOIN   ballots b ON b.voter_id = v.voter_id
WHERE  b.election_id = 7
ORDER  BY v.last_name;
last_namemethod
Nakamuramail
Oseimail
Reyesin-person

Verify: against the ballot rows

Why: Election 7 has ballots 1, 2 and 3, belonging to voters 101, 102 and 103. Three rows, and the surnames match. Patel and Boateng are absent because they cast no ballot.

30. Why the inner join dropped two voters

Picture it

Follow one unmatched voter through the join.

Figure (svg): Four boxes tracing a left join, showing every voter kept, matched voters gaining ballot data, unmatched voters gaining nulls, and a filter on null isolating them

The unmatched rows are not lost by accident; an inner join removes them on purpose.

Which is fine when you want voters who voted, and disastrous when the question was who did not.

31. Now the opposite question

Prediction

Who did NOT cast a ballot in election 7?

Predict first

Which approach finds them?

  • An inner join with a NOT in the WHERE clause
  • A left join, then keep the rows where the ballot columns are NULL
  • Count the ballots and subtract
  • There is no way to do it in one query

Correct: A left join, then keep the NULL rows.

Why: An inner join has already removed the voters you are looking for, so no WHERE clause can bring them back. The left join keeps them with NULL ballot columns, and testing for NULL is what isolates them. This shape is called an anti-join and it appears on nearly every database assignment.

32. Worked example: the anti-join

Worked example

Every active voter who has no ballot in election 7.

Left join so nobody is dropped

Why: The join must keep all voters, so voters goes on the left.

Put the election filter in the join condition, not in WHERE

Why: This is the subtle part. In WHERE it would run after the join and remove the NULL rows you are trying to keep.

Keep only the rows that found no match

Why: A NULL primary key on the right side can only mean the join matched nothing.

SELECT v.last_name
FROM   voters v
LEFT   JOIN ballots b
       ON b.voter_id = v.voter_id AND b.election_id = 7
WHERE  b.ballot_id IS NULL
  AND  v.status = 'active'
ORDER  BY v.last_name;
last_namewhy
Boatengactive, and no ballot in election 7

Verify: by listing who was excluded and why

Why: Reyes, Osei and Nakamura all have ballots in election 7. Patel has none, but is inactive so the status filter removes him. That leaves Boateng, and one row is the right answer.

33. Why the election filter moves into the ON clause

Picture it

Same query, one clause moved, completely different answer.

Figure (svg): Two bars comparing a filter placed in the ON clause which returns the intended rows against the same filter in the WHERE clause which returns none

In WHERE, the test runs after the join and throws away every NULL row.

The rule: conditions on the right-hand table of a left join belong in the ON clause. Conditions on the left-hand table belong in WHERE.

34. Trap: filtering the outer table in WHERE

Trap

The trap

The anti-join, with the election test moved.

Annotate

  • For an unmatched voter, b.election_id is NULL, and NULL = 7 is not true.
  • So this line silently deletes exactly the rows the query exists to find.
  • The two conditions now contradict each other, and the query returns nothing at all.

Get zero rows and no error

Why: The database is perfectly happy. It just quietly turned the left join back into an inner join.

The fix

Keep the right-hand condition inside the join.

LEFT JOIN ballots b
  ON b.voter_id = v.voter_id AND b.election_id = 7
WHERE b.ballot_id IS NULL
votermatched a ballot in election 7?b.ballot_idsurvives the WHERE
Reyesyes, ballot 11no
Oseiyes, ballot 22no
Nakamurayes, ballot 33no
PatelnoNULLyes
BoatengnoNULLyes

Now the filter shapes what counts as a match

Why: Rather than filtering the result after the fact.

Test it by removing the IS NULL line

Why: You should see every voter, with NULLs for the ones who did not vote in election 7. If you do not, the join is still behaving as an inner one.

35. NULL Is Not a Value

Section

Section 5

36. NULL means unknown, and unknown breaks comparison

Concept

NULL is not zero and not an empty string. It is the absence of a value, and any comparison with it produces neither true nor false.

A WHERE clause keeps a row only when the test comes out true. Neither true nor false is not true, so the row goes.

PostgreSQL 16 Documentation — SQL Language — Comparison Functions and Operators, and the three-valued logic

37. Three outcomes, not two

Picture it

SQL comparisons have a third answer that most languages do not.

Figure (svg): Three boxes showing that one equals one is true, one equals two is false, and null equals null is null rather than true

Which is why IS NULL exists as separate syntax.

38. Which statement about NULL holds?

Two truths and a lie

The precincts table has a county column that is NULL for one row.

Eliminate the wrong options

Which is true?

  • A. WHERE county <> 'Howard' will not return the row with the NULL county.
  • B. COUNT(county) and COUNT(*) return the same number.
  • C. WHERE county = NULL finds the row.
  • D. NULL sorts as an empty string in ORDER BY.

Survives elimination: A

Why: An inequality against NULL is also NULL, so the row is dropped by a test that reads as though it should keep it. This is the most common source of quietly missing rows in a report.

39. Fix the NULL test

Fill the middle

Find every precinct whose county has not been recorded.

Fill in the blanks

SELECT name FROM precincts WHERE county IS NULL;

Why: IS NULL is dedicated syntax precisely because the equality operator cannot do this job. Its partner is IS NOT NULL, and together they are the only reliable way to test for absence.

40. Changing Data

Section

Section 6

41. Three statements, one dangerous habit

Concept

INSERT adds rows, UPDATE changes them, DELETE removes them. All three take a WHERE clause, and two of them are catastrophic without one.

INSERT INTO voters (voter_id, first_name, last_name, precinct_id, status)
VALUES (106, 'Femi', 'Adeyemi', 3, 'active');

UPDATE voters SET status = 'inactive' WHERE voter_id = 104;

DELETE FROM ballots WHERE ballot_id = 4;
statementrows affected hererows affected with no WHERE
INSERT1not applicable
UPDATE1every row in the table
DELETE1every row in the table

SQLite — Query Language Documentation — the syntax diagrams for all three

42. The difference one clause makes

Picture it

Same statement, one line missing.

Figure (svg): Two bars comparing an update with a where clause affecting one row against the same update without one affecting all five

There is no undo without a transaction.

The habit that prevents it: write the statement as a SELECT first, check the row count, then change the first word.

43. Worked example: change a status safely

Worked example

Mark voter 104 inactive, without risking the rest of the table.

Write it as a SELECT first

Why: Same FROM, same WHERE. You are checking which rows the statement will touch.

Read the row count

Why: One row. That is the confirmation that the WHERE clause is doing what you meant.

Only now turn it into an UPDATE

Why: The WHERE clause is already proven, so nothing about the risky version is being guessed.

-- step 1: prove the WHERE clause
SELECT * FROM voters WHERE voter_id = 104;

-- step 2: same WHERE, now as an update
UPDATE voters SET status = 'inactive' WHERE voter_id = 104;
stepwhat you seewhat it proves
the SELECT1 row, Dev Patelthe WHERE clause matches exactly one voter
the UPDATEUPDATE 1exactly one row changed
a second SELECTstatus is inactivethe change landed

Verify: by selecting the row again

Why: If the reported count is anything other than one, roll back before doing anything else.

44. The whole safe-change session

Picture it

Six lines. The two counts are the only things you actually read.

Figure (svg): A psql session opening a transaction, running a select that returns one row, running an update that reports one row changed, and committing

SELECT says one row; UPDATE says one row; they agree, so commit.

If the UPDATE had said 2, the next word typed would be ROLLBACK and nothing would have happened.

45. Why does the database not just warn you?

Socratic

A minute on this before moving on.

Discussion prompt

An UPDATE with no WHERE clause is almost always a mistake. Why do databases run it anyway?

Hint: What legitimate reason might someone have for updating every row?

Answer:

Because it is not always a mistake. Setting a column on an entire table is a legitimate operation, and the database cannot read intent.

The protection is meant to come from elsewhere: run inside a transaction so you can roll back, and use a client configured to require confirmation.

Which means the safety is your habit, not the tool's feature.

PostgreSQL 16 Documentation — SQL Language — Transactions

46. Transactions give you an undo

Concept

Wrap a change in a transaction and nothing is permanent until you commit. If the row count surprises you, roll back and nothing happened.

BEGIN;

UPDATE voters SET status = 'inactive' WHERE precinct_id = 2;
-- reports: UPDATE 2

-- expected 1? then:
ROLLBACK;
-- happy? then:
COMMIT;
commandeffectreversible
BEGINopens a transaction-
UPDATEchanges rows, visible only to youyes
ROLLBACKdiscards everything since BEGIN-
COMMITmakes it permanent and visibleno

This is the single most useful habit in this whole deck, and it takes six extra keystrokes.

PostgreSQL 16 Documentation — SQL Language — Transactions

47. Order the safe-change routine

Ranking

Five steps that make a destructive statement survivable.

Put in order

  1. Open a transaction with BEGIN
  2. Run the WHERE clause as a SELECT and read the row count
  3. Run the UPDATE or DELETE
  4. Compare the reported count against the count from the SELECT
  5. COMMIT if they match, ROLLBACK if they do not

Why: The transaction opens first so that everything after it is reversible, including the SELECT. Comparing the two counts is the actual check; without it the transaction is just ceremony.

48. Subqueries

Section

Section 7

49. A query inside a query

Concept

A subquery runs first and hands its result to the outer query. The commonest form tests membership: is this value in that list?

Use it when you need the other table only to decide which rows to keep, not to show any of its columns.

PostgreSQL 16 Documentation — SQL Language — Subquery Expressions

50. Worked example: voters in a whole county

Worked example

List every voter registered in Anne Arundel county. The county lives in precincts; the voters live in voters.

Write the inner query first, on its own

Why: It should return a list of precinct ids and nothing else. Run it separately before nesting it.

Check what it returns

Why: Precincts 1 and 2 are Anne Arundel; 3 is Howard.

Nest it behind IN

Why: The outer query now keeps voters whose precinct is in that list.

SELECT last_name
FROM   voters
WHERE  precinct_id IN (
         SELECT precinct_id FROM precincts
         WHERE  county = 'Anne Arundel'
       )
ORDER  BY last_name;
stepresult
inner query alone1, 2
outer queryNakamura, Osei, Patel, Reyes
excludedBoateng, who is in Westgate

Verify: by counting from the sample data

Why: Precincts 1 and 2 hold four of the five voters, so four rows. Only Boateng, in precinct 3, is missing.

51. Three routes to the same rows

Picture it

A join, an IN subquery and an EXISTS subquery can all answer this. They differ in what comes back.

Figure (svg): A vertical flow comparing join, IN and EXISTS as three ways of relating two tables

Pick by what you need in the result, not by which one you remember.

Join when you want the other table's columns. IN when you want a membership test against a short list. EXISTS when you only care whether a match exists.

52. EXISTS asks only whether a match exists

Concept

A correlated subquery refers to the outer row. EXISTS stops as soon as it finds one match, which makes it the natural way to express 'has at least one'.

SELECT v.last_name
FROM   voters v
WHERE  NOT EXISTS (
         SELECT 1 FROM ballots b
         WHERE  b.voter_id = v.voter_id
       );
voterdoes a matching ballot exist?NOT EXISTSin the result
Reyesyes, two of themfalseno
Oseiyesfalseno
Nakamurayesfalseno
Patelnotrueyes
Boatengnotrueyes

This is the anti-join from section four, written a second way. Both are correct; teams differ on which they prefer.

PostgreSQL 16 Documentation — SQL Language — EXISTS

53. Trap: NOT IN against a column that can be NULL

Trap

The trap

The same question, written with NOT IN.

Annotate

  • If any row of ballots has a NULL voter_id, the whole NOT IN evaluates to NULL for every voter.
  • Because 'x is not in the list' becomes 'x <> NULL', which is neither true nor false.
  • The query returns zero rows, with no error and no warning.

Get an empty result that looks like a data problem

Why: Hours get lost here, because the SQL reads perfectly and the data looks fine.

The fix

Use NOT EXISTS, or add an explicit NULL guard.

SELECT last_name FROM voters v
WHERE NOT EXISTS (
  SELECT 1 FROM ballots b WHERE b.voter_id = v.voter_id
);
formwith a NULL in the subqueryverdict
NOT INreturns zero rowssilently wrong
NOT EXISTSreturns the right rowssafe
LEFT JOIN with IS NULLreturns the right rowssafe

Prefer NOT EXISTS by default

Why: It behaves the same whether or not NULLs are present, which removes an entire class of bug you would otherwise have to remember.

54. Three shapes, side by side

Comparison

Fill in what each one gives you.

Comparison matrix

formreturns the other table's columns?safe with NULLs?
JOINyesyes
IN subquerynoyes for IN, no for NOT IN
EXISTS subquerynoyes
LEFT JOIN with IS NULLyes, as NULLsyes

If you only ever remember one thing from this section: NOT IN is the one with the trap.

55. Explain a subquery to someone who only knows joins

Explain it

Two sentences, out loud.

Discussion prompt

When would you reach for a subquery rather than a join?

Hint: What happens to a join when one voter has two ballots?

Answer:

When the second table is only there to decide which rows to keep, and none of its columns need to appear in the answer.

A join would work too, but it can duplicate rows when the match is one-to-many, and then you need DISTINCT to clean up after yourself. A subquery never duplicates.

PostgreSQL 16 Documentation — SQL Language — Subquery Expressions

56. How sure are you?

Commit first

Answer, then rate your confidence honestly.

Predict first

Voter 101 cast two ballots. How many rows does SELECT v.last_name FROM voters v JOIN ballots b ON b.voter_id = v.voter_id WHERE v.voter_id = 101 return?

  • 1
  • 2
  • 4
  • 0

Correct: 2.

Why: A join produces one row per matching pair, so a voter with two ballots appears twice. This is the duplication that makes people reach for DISTINCT, and it is the reason a subquery is sometimes the cleaner tool.

57. Pattern: turning an English question into SQL

Pattern

Every question on this assignment fits this. Answer the five in order and the query is written.

the question saysthe clause it becomes
'list', 'show', 'which'SELECT, and the columns named
a noun that is a tableFROM
'where', 'only', 'active', 'after 2015'WHERE
'per', 'each', 'by precinct'GROUP BY
'more than', 'at least', with a countHAVING
'who did not', 'missing', 'never'LEFT JOIN plus IS NULL
'in alphabetical order', 'highest first'ORDER BY
  1. Find the grain first. One row per what? Everything else follows from that answer.
  2. Join only what you need. Every extra table is a chance to duplicate rows.
  3. Check the row count before the rows. It catches most mistakes in one glance.
  4. Never run UPDATE or DELETE outside a transaction.

SQLBolt — interactive SQL lessons — the same progression, with an interactive database to practise on

58. Which tool does each question need?

Discrimination

Naming the shape before writing anything is most of the work.

Sort into buckets

Which clause or pattern answers each question?

join plus WHERE
Which voters are in Harbor precinct?
GROUP BY
How many ballots were cast by method?
left join plus IS NULL
Which voters never cast a ballot at all?
GROUP BY plus HAVING
Which precincts have more than one active voter?
where
A plain row filter, once the precinct name is available through a join.
group
One row per method, with a count. No filtering of groups is asked for.
anti
The word never means the rows you want are the ones an inner join would remove.
having
One row per precinct, with a count, and then a condition on that count.

59. Check: the anti-join

Check

Solve it on paper before you click.

Check your understanding

Which query lists voters who have never cast any ballot?

  • A. SELECT v.last_name FROM voters v LEFT JOIN ballots b ON b.voter_id = v.voter_id WHERE b.ballot_id IS NULL (correct)
  • B. SELECT v.last_name FROM voters v JOIN ballots b ON b.voter_id = v.voter_id WHERE b.ballot_id IS NULL
  • C. SELECT v.last_name FROM voters v WHERE v.voter_id <> ballots.voter_id
  • D. SELECT v.last_name FROM voters v LEFT JOIN ballots b ON b.voter_id = v.voter_id WHERE b.ballot_id = NULL

Answer: A

Why: The left join keeps every voter, unmatched ones get NULL in the ballot columns, and the IS NULL test isolates exactly those. Against the sample data it returns Patel and Boateng.

Why B tempts people
An inner join has already discarded the unmatched voters, so nothing can be NULL and the query returns no rows.
Why C tempts people
The ballots table is never joined, so the reference to it is invalid. Even written correctly, comparing against one row of a table is not how absence is tested.
Why D tempts people
Equality against NULL evaluates to NULL, never true, so this returns nothing. It has to be IS NULL.

60. Check: how many rows?

Check

Solve it on paper before you click.

Check your understanding

SELECT COUNT(*) FROM voters v JOIN ballots b ON b.voter_id = v.voter_id; against the sample data returns what?

  • A. 4 (correct)
  • B. 5
  • C. 3
  • D. 20

Answer: A

Why: The join produces one row per ballot that has a matching voter, and there are four ballots, all with valid voters. Voter 101 appears twice because she cast two ballots.

Why B tempts people
Five is the number of voters, not the number of matched pairs. Two voters have no ballots and one has two.
Why C tempts people
Three is the number of distinct voters who appear, not the number of rows. Counting distinct voter ids would give three.
Why D tempts people
Twenty would be the cross product of five voters and four ballots, which is what you get if the ON condition is missing.

61. Exit ticket

Exit ticket

One honest answer, and it decides where the next session starts.

Predict first

Which of these is still murkiest?

  • Reading the schema and knowing which columns join
  • WHERE versus HAVING
  • Inner join versus left join
  • The anti-join pattern for 'who did not'
  • NULL and why comparisons fail

Correct: Whichever you named is where we start.

Why: All five are separable and all five are drillable against this same tiny database, which is the point of keeping it to five voters: every answer can be checked by hand in under a minute.

62. Rebuild the schema from memory

Connect it up

Twenty minutes, on paper. This is worth more than re-reading the notes.

Draw it

Draw the four tables and their columns from memory. Draw an arrow for every foreign key. Then write, beside each arrow, one English question that would need that join.

The arrows you could not place are the ones to look up. Bring the page.

63. What you can do now

Recap

Six sections, and the anti-join in section four is the one that shows up on every database assignment ever set.

queryrows against the sample data
active voters, alphabetical4
voters per precinct3 groups: 2, 2, 1
precincts with more than one active voter1: Northside
voters in election 73
active voters not in election 71: Boateng
voters joined to ballots4

PostgreSQL 16 Documentation — SQL Language — every statement in this deck, with the full syntax

Sources

  1. PostgreSQL 16 Documentation — SQL Language
  2. SQLite — Query Language Documentation
  3. PostgreSQL Documentation — Table Expressions and Joined Tables
  4. SQLBolt — interactive SQL lessons
  5. Markus Winand, Use The Index, Luke — how indexes and query plans interact
  6. ISO/IEC 9075, Information technology — Database languages — SQL — International Organization for Standardization

Want this taught 1-on-1? Alexander tutors IT Support & Networking — $55/session, free consultation.

Book on Wyzant · Text (657) 465-8108