AP Exams (Advanced Placement) · Databases
SQL Joins, Groups and NULL
Join the campus-shop tables, group sales, and handle missing values in SQL.
The kiosk still has three tables. This lesson puts them side by side: join Sale to Item to see names and prices, group to count per student, and treat a missing phone as NULL rather than zero.
- AP Exams (Advanced Placement)
- Medium level
- 6 concepts
- 4 practice questions
1INNER JOIN and LEFT JOIN
A join asks two tables to share a row when a condition holds. INNER JOIN Sale with Item on item_id keeps a pair only when both sides match. Sale 100 pairs with Samosa; Sale 101 pairs with Tea. Every kiosk sale has an item, so INNER JOIN Sale–Item returns all four sales, now carrying item_name and price_rs.
LEFT OUTER JOIN keeps every row of the left table even when the right side has no match, and fills the right columns with NULL. If we LEFT JOIN Student to Sale, a student who has not bought yet would still appear, with empty sale columns. INNER JOIN would drop that student. Use LEFT when "keep everyone on this side" is the question.
Figure. INNER JOIN matches sale 100 to item 10. The result row can now say "2 Samosa".
Who bought what
- Match keySale.item_id = Item.item_id.
- INNERKeep only matching pairs — four sales, each with a name and a price.
- LEFT from StudentKeep every student; NULL-pad sale columns if they have not bought.
| sale_id | item_name | qty | price_rs |
|---|---|---|---|
| 100 | Samosa | 2 | 20 |
| 101 | Tea | 1 | 12 |
Who bought what
SELECT sale.sale_id, student.name, item.item_name, sale.qty
FROM sale
INNER JOIN student ON sale.student_id = student.student_id
INNER JOIN item ON sale.item_id = item.item_id;Asha's spend
INNER JOIN Sale to Item. Asha's rows are sale 100 (2 × Rs 20) and sale 102 (1 × Rs 40). What did she spend?
- sale 100: 2 × 2040
- sale 102: 1 × 4040
- Asha total Rs80
Pro tip. The price lives on Item. The join copies it onto each sale so qty × price is a row fact.
Coding lab. Join Sale to Item and print Asha's lines runs in the app, with checks on your output.
A LEFT OUTER JOIN from Student to Sale is used when you must
- Drop every student who has not bought yet
- Keep every student, with NULLs in Sale columns when unmatched
- Always duplicate every sale
LEFT OUTER preserves the left input and NULL-pads non-matches on the right.
2GROUP BY and aggregates
An aggregate is a number computed from several rows: COUNT, SUM, AVG, MIN, MAX. GROUP BY says "compute that number once per group". GROUP BY student_id on Sale makes three groups: Asha (two sales), Ravi (one), Meera (one). COUNT(*) per group is 2, 1, 1.
Without GROUP BY, an aggregate covers the whole table: SUM(qty) on Sale is 2+1+1+3 = 7 items sold. You cannot SELECT name and SUM(qty) together unless name is in the GROUP BY (or is itself an aggregate). The engine needs to know which name goes with which pile.
Figure. GROUP BY student_id piles the four sales into three groups. Aggregates then run once per pile.
Items sold per student
- GroupPile Sale rows by student_id: {1}, {2}, {3}.
- CountAsha 2 rows, Ravi 1, Meera 1.
- Sum qtyAsha 2+1=3 items; Ravi 1; Meera 3.
| student_id | COUNT(*) | SUM(qty) |
|---|---|---|
| 1 Asha | 2 | 3 |
| 2 Ravi | 1 | 1 |
| 3 Meera | 1 | 3 |
Items each student bought
SELECT student_id, COUNT(*) AS sales, SUM(qty) AS items
FROM sale
GROUP BY student_id;Whole-table SUM versus per-student SUM
What is SUM(qty) on all of Sale, and SUM(qty) in Asha's group?
- 2 + 1 + 1 + 37 items in the shop today
- Asha qty 2 + 13
- check: 3 + 1 + 37
Pro tip. The per-group sums add back to the ungrouped sum when every row is in exactly one group.
SUM(qty) without GROUP BY on Sale returns
- One number: 7
- Three numbers, one per student
- Four numbers, one per sale
No GROUP BY means one pile — the whole table. SUM(qty) = 7.
3WHERE versus HAVING
WHERE tests a row before groups exist. HAVING tests a group after aggregates exist. "Sales of tea" is a row test: WHERE item_id = 11. "Students with at least two sales" is a group test: GROUP BY student_id HAVING COUNT(*) >= 2 — that keeps Asha and drops Ravi and Meera.
COUNT(*) >= 2 cannot live in WHERE, because a single sale row does not have a count yet. Put row filters in WHERE (they shrink the piles before you count) and put aggregate filters in HAVING.
Figure. WHERE cannot see COUNT(*). HAVING runs after GROUP BY and can.
Students with two or more sales
- WHEREOptional row filter first — we keep all four sales here.
- GROUP BYPile by student_id.
- HAVINGKeep groups whose COUNT(*) >= 2 — only Asha.
| Question | Clause |
|---|---|
| item_id = 11 | WHERE (row) |
| COUNT(*) >= 2 | HAVING (group) |
| qty > 1 | WHERE (row) |
| SUM(qty) >= 3 | HAVING (group) |
Repeat buyers
SELECT student_id, COUNT(*) AS n
FROM sale
GROUP BY student_id
HAVING COUNT(*) >= 2;
-- Asha only: student_id 1, n = 2Which clause filters groups produced by GROUP BY, rather than individual rows?
- WHERE
- HAVING
- FROM
HAVING filters aggregated groups. WHERE filters rows before grouping and cannot reference aggregates.
4NULL is unknown, not zero
NULL means "we do not have this value". It is not 0 and not the empty string. Suppose Meera's phone is missing. The comparison phone = NULL is not TRUE and not FALSE — it is UNKNOWN. WHERE phone = NULL therefore drops the row. The test that keeps missing phones is WHERE phone IS NULL.
The same three-valued logic makes NULL = NULL UNKNOWN. Two missing phones are not "equal" in SQL; they are two unknowns. Use IS NULL / IS NOT NULL, never = NULL.
NULL = NULL is UNKNOWN. The IS NULL test is the one that keeps Meera when her phone is missing.
Why = NULL drops the row
- NULL means unknownMeera's phone is not a value we can compare.
- = yields UNKNOWNUNKNOWN is not TRUE, so WHERE rejects the row.
- IS NULLThe dedicated test for "this mark is missing".
| Expression | Result |
|---|---|
| phone = NULL | UNKNOWN |
| NULL = NULL | UNKNOWN |
| phone IS NULL | TRUE when missing |
| phone IS NOT NULL | TRUE when present |
Find the missing phone
SELECT name FROM student
WHERE phone IS NULL;
-- not: WHERE phone = NULLWhat is the result of evaluating NULL = NULL in SQL?
- UNKNOWN
- TRUE
- FALSE
NULL is unknown. Comparing unknown to unknown with = yields UNKNOWN. Use IS NULL.
5COUNT(*) versus COUNT(column)
COUNT(*) counts rows. COUNT(phone) counts non-NULL values of phone. If Student has three rows and Meera's phone is NULL, COUNT(*) is 3 and COUNT(phone) is 2. Every other aggregate (SUM, AVG, MIN, MAX) also skips NULLs. Only COUNT(*) treats a NULL row as a row that is still there.
That is why "how many students?" is COUNT(*) and "how many phones do we have on file?" is COUNT(phone). Mixing them is the usual trap on a column that allows missing values.
Figure. Three student rows exist. Only two phones are on file. COUNT(*) sees rows; COUNT(phone) sees values.
Three students, one missing phone
- RowsAsha, Ravi, Meera — COUNT(*) = 3.
- Phones on fileAsha and Ravi — COUNT(phone) = 2.
- Skip ruleSUM/AVG/MIN/MAX skip NULLs the same way COUNT(col) does.
COUNT with one NULL
Student has 3 rows; phone is NULL on 1 of them. What do COUNT(*) and COUNT(phone) return?
- rows3
- NULL phones1
- COUNT(*)3
- COUNT(phone) = 3 - 12
Pro tip. COUNT(*) includes the NULL row; COUNT(phone) does not.
phone has 10 rows, 3 NULL. COUNT(*) and COUNT(phone) are
- 7 and 10
- 10 and 7
- 7 and 7
COUNT(*) = 10 rows; COUNT(phone) = 7 non-NULL values.
6DISTINCT, UNION and UNION ALL
SQL SELECT keeps duplicate rows. \pi_{\text{item\_id}}(\text{Sale}) in algebra would be {10, 11, 12}. SELECT item_id FROM sale returns 10, 11, 12, 11 — Tea twice. DISTINCT asks SQL to drop the extra 11.
UNION of two queries also drops duplicates (set union). UNION ALL concatenates and keeps them. Use UNION ALL when you already know the piles are disjoint and you do not want the extra distinct pass — for example lunch sales stacked on snack sales that cannot share a sale_id.
SELECT item_id from the four sales lists 11 twice. DISTINCT or UNION (not UNION ALL) drop the extra Tea.
| Form | item_id from Sale |
|---|---|
| Algebra \pi | 10, 11, 12 |
| SQL SELECT | 10, 11, 12, 11 |
| SELECT DISTINCT | 10, 11, 12 |
| UNION ALL of two copies | eight rows |
UNION versus UNION ALL — which statement is true?
- UNION keeps duplicates; UNION ALL removes them
- UNION removes duplicates; UNION ALL keeps them
- Both always remove duplicates
UNION is set union; UNION ALL is multiset concatenation.
Notes
- INNER JOIN keeps matching keys; LEFT OUTER JOIN keeps all left rows and NULL-pads the rest.
- GROUP BY computes COUNT/SUM/AVG once per group; ungrouped aggregates cover the whole table.
- WHERE filters rows; HAVING filters groups. Aggregates belong in HAVING.
- NULL comparisons yield UNKNOWN; use IS NULL. COUNT(*) counts rows; COUNT(col) skips NULLs.
- SQL SELECT keeps duplicates; DISTINCT and UNION remove them; UNION ALL keeps them.
Formulas
- INNER JOIN ON sale.item_id = item.item_id
- GROUP BY student_id HAVING COUNT(*) >= 2
- COUNT(*) = rows; COUNT(col) = non-NULL values of col
Exam traps & shortcuts
- LEFT JOIN when you must keep every left row.
- WHERE cannot hold COUNT(*); that is HAVING.
- NULL = NULL is UNKNOWN.
Reference tables
| Student | Sales | Spend Rs |
|---|---|---|
| Asha | Samosa×2, Notebook×1 | 80 |
| Ravi | Tea×1 | 12 |
| Meera | Tea×3 | 36 |
Recap
Two tables, then a pile, then a missing value.
- INNER
- Keep matches only. Sale ⋈ Item gives four named lines.
- LEFT
- Keep every left row; NULL-pad the right when unmatched.
- GROUP BY
- Asha 2 sales / 3 items; whole-table SUM(qty) = 7.
- HAVING
- COUNT(*) >= 2 lives here, not in WHERE.
- NULL
- IS NULL, not = NULL. COUNT(*) vs COUNT(col).
Practise SQL Joins, Groups and NULL
Reading is free and needs no account. Practice, mocks and progress live in the app.
- 4 exam-style questions on this topic, with explanations
- A 3-question practice set that ends the chapter
- Timed mocks scored with the real marking scheme
- Readiness tracked per topic, kept on your device