Learning Goals
3 min- Recognise foreign keys — the columns that point from one table to another.
- Write
SELECT … FROM A JOIN B ON A.k = B.k. - Disambiguate columns with table aliases (
a,s,c). - Chain two joins for three-table reports.
Warm-Up · Set Up Two Tables
5 minCREATE TABLE classes ( class_id INTEGER PRIMARY KEY, name TEXT, teacher TEXT ); CREATE TABLE pupils ( id INTEGER PRIMARY KEY, name TEXT, class_id INTEGER, -- foreign key → classes.class_id score INTEGER ); INSERT INTO classes VALUES (1, 'Form 1A', 'Cikgu Lim'), (2, 'Form 1B', 'Cikgu Mei'), (3, 'Form 1C', 'Cikgu Anand'); INSERT INTO pupils VALUES (1, 'Aisyah', 1, 88), (2, 'Wei Jie', 1, 75), (3, 'Suresh', 2, 92), (4, 'Mei', 2, 80), (5, 'Anita', 1, 95), (6, 'Razif', 2, 55); -- (Form 1C has no pupils — yet.)
The arrow: pupils.class_id → classes.class_id. That column is the foreign key.
A JOIN puts two rows from two tables side-by-side wherever the key matches. Filter, aggregate and order behave exactly as before — but now your row has columns from both tables.
New Concept · INNER JOIN ON
14 minBasic syntax
SELECT p.name, p.score, c.name AS class_name, c.teacher FROM pupils AS p INNER JOIN classes AS c ON p.class_id = c.class_id;
name score class_name teacher Aisyah 88 Form 1A Cikgu Lim Wei Jie 75 Form 1A Cikgu Lim Suresh 92 Form 1B Cikgu Mei Mei 80 Form 1B Cikgu Mei Anita 95 Form 1A Cikgu Lim Razif 55 Form 1B Cikgu Mei
Form 1C is missing — INNER JOIN only includes rows that match on both sides. Form 1C has no pupils, so it's dropped.
Aliases are not optional
The moment two tables both have a name column, the bare query becomes ambiguous. FROM pupils AS p gives the table a short nickname, and p.name / c.name disambiguates.
Join + filter + aggregate
-- Average score per class with teacher name SELECT c.name AS class, c.teacher, ROUND(AVG(p.score), 1) AS avg_score FROM pupils AS p INNER JOIN classes AS c ON p.class_id = c.class_id GROUP BY c.class_id ORDER BY avg_score DESC;
Three tables — chained JOIN
-- Add a subjects table CREATE TABLE subjects (subject_id INTEGER PRIMARY KEY, name TEXT); -- And a marks bridge table CREATE TABLE marks ( pupil_id INTEGER, subject_id INTEGER, score INTEGER, PRIMARY KEY (pupil_id, subject_id) ); -- A 3-table join SELECT p.name AS pupil, s.name AS subject, m.score FROM pupils AS p JOIN marks AS m ON m.pupil_id = p.id JOIN subjects AS s ON s.subject_id = m.subject_id;
INNER JOIN is the default — JOIN alone means the same thing. Use JOIN in real code; it's shorter and standard.
Worked Example · Top Scorer Per Class
12 min-- Best pupil in each class, with teacher name SELECT class, teacher, name AS top_pupil, score FROM ( SELECT c.name AS class, c.teacher, p.name, p.score, ROW_NUMBER() OVER (PARTITION BY c.class_id ORDER BY p.score DESC) AS rn FROM pupils AS p JOIN classes AS c ON p.class_id = c.class_id ) WHERE rn = 1;
class teacher top_pupil score Form 1A Cikgu Lim Anita 95 Form 1B Cikgu Mei Suresh 92
Read the diff
Three skills combined:
JOIN— to bring teacher into the same row as pupil.ROW_NUMBER() OVER (PARTITION BY c.class_id ORDER BY p.score DESC)— rank within each class.- Outer SELECT filtering
rn = 1— keep only the winner.
This single query replaces what used to be a multi-step Python script.
Try It Yourself
13 minFor every pupil, print their name + class name. Two columns only.
Hint
SELECT p.name, c.name AS class FROM pupils p JOIN classes c ON p.class_id = c.class_id;
For pupils with score ≥ 90, print pupil name, score, teacher name.
Hint
SELECT p.name, p.score, c.teacher FROM pupils p JOIN classes c ON p.class_id = c.class_id WHERE p.score >= 90;
Sort by pupil name and print pupil, class name, teacher.
Hint
SELECT p.name, c.name AS class, c.teacher FROM pupils p JOIN classes c ON p.class_id = c.class_id ORDER BY p.name;
Mini-Challenge · Two-Way Class Report
8 minFor each class, return: class name, teacher name, number of pupils, average score, and the top-scoring pupil. Sort by average score descending. Use joins + GROUP BY together.
Show one possible solution
SELECT c.name AS class, c.teacher, COUNT(p.id) AS pupils, ROUND(AVG(p.score), 1) AS avg_score, MAX(p.score) AS top_score FROM classes c JOIN pupils p ON p.class_id = c.class_id GROUP BY c.class_id ORDER BY avg_score DESC;
Note this uses INNER JOIN, so Form 1C (no pupils) is silently dropped. Tomorrow's LEFT JOIN lesson fixes that.
Recap
3 minA foreign key in one table points to a primary key in another. JOIN ... ON matches them. Use aliases so columns don't collide. INNER JOIN drops rows that lack a match — that's usually what you want, sometimes a footgun. Tomorrow's LEFT JOIN is the alternative.
Vocabulary Card
- foreign key
- A column whose value identifies a row in another table.
- JOIN ON
- Combine rows from two tables where a condition matches.
- alias (AS)
- A short name for a table or column.
FROM pupils AS p. - INNER vs OUTER
- INNER keeps only matched rows; OUTER (next lesson) also keeps unmatched.
Homework
4 minCreate three tables: authors(id, name), books(id, title, author_id, year), loans(id, book_id, borrower, due_date). Insert sample data with at least one author whose books are never loaned, and at least one book that's on loan. Write four join queries:
- Every book and its author name.
- Every loan with the book title and author name (3-table join).
- Books that are currently on loan.
- Total number of books per author (use a join + GROUP BY).
SELECT b.title, a.name AS author FROM books b JOIN authors a ON a.id = b.author_id; SELECT l.id, b.title, a.name AS author, l.borrower, l.due_date FROM loans l JOIN books b ON b.id = l.book_id JOIN authors a ON a.id = b.author_id; SELECT b.title, l.borrower, l.due_date FROM books b JOIN loans l ON l.book_id = b.id; SELECT a.name, COUNT(b.id) AS books FROM authors a JOIN books b ON b.author_id = a.id GROUP BY a.id;