SQL JOIN practice
Seven join exercises with worked answers, plus the four join mistakes that cost the most points: lost rows, wrong counts, duplicated rows and broken multi-table chains.
Play SQL Crime Labs free →
JOIN practice organized by the mistake each one teaches you to avoid. Every number and table below comes from running the query on the game's database, so you can check your own answers against it.
INNER JOIN vs LEFT JOIN
An INNER JOIN keeps only rows that have a match on both sides. A LEFT JOIN keeps every row from the left table and fills the right side with NULL when nothing matches. Some suspects are stateless (their country_id is NULL), so the two joins return different row counts:
SELECT COUNT(*) FROM suspects s
JOIN countries c ON c.country_id = s.country_id;
SELECT COUNT(*) FROM suspects s
LEFT JOIN countries c ON c.country_id = s.country_id;
The extra rows are the suspects with no country. The LEFT JOIN keeps them with a NULL country:
| first_name | country_id | country |
|---|
| Ian | NULL | NULL |
| Kara | NULL | NULL |
2 rows
Counting matches correctly: COUNT(*) vs COUNT(column)
After a LEFT JOIN, a country with no suspects still produces one row (with NULLs). COUNT(*) counts that row, so it reports 1. COUNT(s.suspect_id) skips NULLs and correctly reports 0:
SELECT c.name, COUNT(*) AS count_star, COUNT(s.suspect_id) AS count_suspects
FROM countries c
LEFT JOIN suspects s ON s.country_id = c.country_id
GROUP BY c.country_id, c.name;
| name | count_star | count_suspects |
|---|
| Germany | 1 | 0 |
| Italy | 5 | 5 |
| Japan | 5 | 5 |
| Nigeria | 5 | 5 |
| Mexico | 6 | 6 |
| France | 7 | 7 |
7 rows (first 6 shown)
Why a join can multiply rows
Joining to a one-to-many table repeats the left row once per match. A suspect on three cases appears three times:
| suspects | rows_after_join | distinct_suspects |
|---|
| 40 | 34 | 28 |
1 row
Use COUNT(DISTINCT ...) or aggregate before joining when you need one row per suspect.
Multiple joins: following the chain table by table
To value each informant's seizures you walk four tables: informants → seizures → seizure_items → contraband. Each join follows one foreign key, and the row count changes at every step:
FROM informants i
JOIN seizures s ON s.informant_id = i.informant_id -- one row per seizure
JOIN seizure_items si ON si.seizure_id = s.seizure_id -- one row per item seized
JOIN contraband c ON c.item_id = si.item_id; -- adds name and street value
| step | rows |
|---|
| informants | 30 |
| + JOIN seizures | 60 |
| + JOIN seizure_items | 148 |
| + JOIN contraband | 148 |
4 rows
Informants with no seizures drop out at the first INNER JOIN. Switch it to a LEFT JOIN to keep them. Top informants uses this exact chain.
World Watch-list database
The Global Crimes Bureau's files: suspects and their bosses, the countries they work in, open and closed cases, and who was seen at which scene.
suspects 40 rows · suspect_id, first_name, last_name, country_id, role, bounty_usd, first_seen, boss_id, phone
| column | type | key |
|---|
suspect_id | INTEGER | PK |
first_name | TEXT | |
last_name | TEXT | |
country_id | INTEGER | FK countries (NULL = stateless) |
role | TEXT | |
bounty_usd | INTEGER | |
first_seen | TEXT (YYYY-MM-DD) | |
boss_id | INTEGER | FK suspects |
phone | TEXT (nullable) | |
Sample rows| suspect_id | first_name | last_name | country_id | role | bounty_usd | first_seen | boss_id | phone |
|---|
| 1 | Mateus | Albuquerque | 4 | Kingpin | 245,000 | 2015-03-02 | NULL | +55 21 5550 0101 |
| 2 | Dante | Moreau | 1 | Chief Smuggler | 198,000 | 2016-06-13 | 1 | +33 4 5550 0102 |
| 3 | Priya | Raghavan | 2 | Chief Forger | 176,000 | 2017-01-09 | 1 | +81 6 5550 0103 |
| 4 | Tobias | Alvarez | 3 | Cartel Treasurer | 142,000 | 2018-04-23 | 1 | +234 1 5550 0104 |
| 5 | Grace | Okafor | 4 | Fixer | 151,000 | 2016-11-07 | 1 | +52 664 5550 0105 |
5 rows
countries 7 rows · country_id, name, hq_city, region, agents
| column | type | key |
|---|
country_id | INTEGER | PK |
name | TEXT | |
hq_city | TEXT | |
region | TEXT | |
agents | INTEGER | |
Sample rows| country_id | name | hq_city | region | agents |
|---|
| 1 | Brazil | Rio de Janeiro | South America | 1,200 |
| 2 | France | Marseille | Europe | 650 |
| 3 | Japan | Osaka | Asia | 420 |
| 4 | Nigeria | Lagos | Africa | 380 |
| 5 | Mexico | Tijuana | North America | 300 |
5 rows
cases 8 rows · case_id, title, country_id, opened, closed, loss_usd
| column | type | key |
|---|
case_id | INTEGER | PK |
title | TEXT | |
country_id | INTEGER | FK countries |
opened | TEXT | |
closed | TEXT (NULL = still open) | |
loss_usd | INTEGER | |
Sample rows| case_id | title | country_id | opened | closed | loss_usd |
|---|
| 1 | The Blue Marlin Run | 1 | 2023-01-15 | NULL | 450,000 |
| 2 | Vermeer's Shadow | 6 | 2022-06-01 | 2024-03-31 | 320,000 |
| 3 | Counterfeit Riviera | 2 | 2023-09-01 | NULL | 180,000 |
| 4 | Midnight at the Museum | 3 | 2024-02-01 | 2024-08-31 | 95,000 |
| 5 | The Ledger Heist | 4 | 2023-04-10 | 2024-01-31 | 140,000 |
5 rows
case_suspects 34 rows · suspect_id, case_id, sightings
| column | type | key |
|---|
suspect_id | INTEGER | PK, FK suspects |
case_id | INTEGER | PK, FK cases |
sightings | INTEGER | |
Sample rows| suspect_id | case_id | sightings |
|---|
| 2 | 1 | 120 |
| 8 | 1 | 640 |
| 9 | 1 | 580 |
| 10 | 1 | 710 |
| 11 | 1 | 690 |
5 rows
Port Evidence Room database
Informants across five ports, the contraband in the vault, and every seizure their tips led to.
informants 30 rows · informant_id, name, port, signed_date, reliability
| column | type | key |
|---|
informant_id | INTEGER | PK |
name | TEXT | |
port | TEXT | |
signed_date | TEXT (YYYY-MM-DD) | |
reliability | TEXT (bronze/silver/gold) | |
Sample rows| informant_id | name | port | signed_date | reliability |
|---|
| 1 | Alicia Moreno | Marseille | 2022-01-14 | gold |
| 2 | Brandon Lewis | Rotterdam | 2022-03-02 | silver |
| 3 | Carmen Diaz | Singapore | 2022-04-19 | bronze |
| 4 | Derek Shaw | Marseille | 2022-05-30 | silver |
| 5 | Erin Walsh | Santos | 2022-07-11 | gold |
5 rows
contraband 20 rows · item_id, name, category, street_value, units_in_vault
| column | type | key |
|---|
item_id | INTEGER | PK |
name | TEXT | |
category | TEXT | |
street_value | REAL | |
units_in_vault | INTEGER | |
Sample rows| item_id | name | category | street_value | units_in_vault |
|---|
| 1 | Cloned Phones | Electronics | 59.99 | 120 |
| 2 | Pirated Speakers | Electronics | 89 | 64 |
| 3 | Fake Charging Hubs | Electronics | 34.5 | 210 |
| 4 | Stolen Monitors | Electronics | 329 | 18 |
| 5 | Skimmed Keyboards | Electronics | 119 | 45 |
5 rows
seizures 60 rows · seizure_id, informant_id, seized_date, status
| column | type | key |
|---|
seizure_id | INTEGER | PK |
informant_id | INTEGER | FK informants |
seized_date | TEXT (YYYY-MM-DD) | |
status | TEXT (confirmed/in_court/pending/dismissed) | |
Sample rows| seizure_id | informant_id | seized_date | status |
|---|
| 1 | 9 | 2023-06-01 | in_court |
| 2 | 18 | 2023-06-17 | in_court |
| 3 | 26 | 2023-06-19 | confirmed |
| 4 | 18 | 2023-06-23 | pending |
| 5 | 16 | 2023-08-01 | confirmed |
5 rows
seizure_items 148 rows · seizure_id, item_id, quantity
| column | type | key |
|---|
seizure_id | INTEGER | PK, FK seizures |
item_id | INTEGER | PK, FK contraband |
quantity | INTEGER | |
Sample rows| seizure_id | item_id | quantity |
|---|
| 1 | 11 | 2 |
| 1 | 7 | 2 |
| 1 | 4 | 3 |
| 2 | 15 | 3 |
| 2 | 5 | 1 |
5 rows
SQL JOIN practice questions with answers
Inner joins (Who works where), LEFT JOIN with counts (Every country counts), finding rows with no match (Dry tips, Off the case board), multiple joins (Top informants, Marseille only) and a self-join (Out-ranking the boss).
1. Who works where Semi-Pro
join · database: World Watch-list
Case request: return each suspect's first_name, last_name and their country's name (call it country). Stateless suspects don't appear.
Hint
JOIN countries ON countries.country_id = suspects.country_id. Short aliases help: FROM suspects s JOIN countries c ON c.country_id = s.country_id.
Show the solution and expected output
SELECT s.first_name, s.last_name, c.name AS country
FROM suspects s
JOIN countries c ON c.country_id = s.country_id;
Why it works: An INNER JOIN pairs rows that match on the ON condition and drops rows with no partner. That's why the two hired guns (NULL country_id) disappear. The beams show each suspect linked to their country.
Other databases: Identical everywhere. JOIN on its own means INNER JOIN.
Expected output (any row order):
| first_name | last_name | country |
|---|
| Mateus | Albuquerque | Nigeria |
| Dante | Moreau | Brazil |
| Priya | Raghavan | France |
| Tobias | Alvarez | Japan |
| Grace | Okafor | Nigeria |
| Leo | Martins | Mexico |
| Hana | Sato | Italy |
| Jamal | Brooks | Brazil |
38 rows (first 8 shown)
Solve this case in the 3D game →
2. Every country counts Semi-Pro
joinleft joingroup · database: World Watch-list
Case request: list every country's name and its headcount of suspects, including countries with nobody on file (they should show 0).
Hint
Start FROM countries and LEFT JOIN suspects so empty countries survive. Count a column from suspects, COUNT(s.suspect_id), not COUNT(*), or empty countries count as 1.
Show the solution and expected output
SELECT c.name, COUNT(s.suspect_id) AS headcount
FROM countries c
LEFT JOIN suspects s ON s.country_id = c.country_id
GROUP BY c.country_id, c.name;
Why it works: LEFT JOIN keeps every row from the left table even with no match, filling the right side with NULL. COUNT(column) skips NULLs, so Germany correctly gets 0. COUNT(*) would count its one NULL-filled row.
Other databases: Identical everywhere. Some databases require every non-aggregated column in GROUP BY, so grouping by both id and name is the portable habit.
Expected output (any row order):
| name | headcount |
|---|
| Brazil | 10 |
| France | 7 |
| Japan | 5 |
| Nigeria | 5 |
| Mexico | 6 |
| Italy | 5 |
| Germany | 0 |
7 rows
Solve this case in the 3D game →
3. Dry tips Semi-Pro
subqueryanti join · database: Port Evidence Room
Case request: which informants have never produced a seizure? Return their name and port.
Hint
Find informant_ids that aren't in the seizures table: WHERE informant_id NOT IN (SELECT informant_id FROM seizures). A LEFT JOIN with WHERE s.seizure_id IS NULL works too.
Show the solution and expected output
SELECT name, port
FROM informants
WHERE informant_id NOT IN (SELECT informant_id FROM seizures);
Why it works: NOT IN with a subquery keeps rows whose value never appears in the list. LEFT JOIN ... IS NULL is the other common way to find rows with no partner.
Other databases: Identical everywhere. Careful: if the subquery can return a NULL, NOT IN returns nothing at all. NOT EXISTS doesn't have that trap.
Expected output (any row order):
| name | port |
|---|
| Erin Walsh | Santos |
| Sean O'Neil | Rotterdam |
| Bianca Torres | Marseille |
| Caleb Price | Santos |
| Dana Brooks | Rotterdam |
| Eli Navarro | Lagos |
6 rows
Solve this case in the 3D game →
4. Off the case board Pro
correlatedsubquery · database: World Watch-list
Case request: which suspects aren't linked to any case? Return first_name and last_name. Use a correlated subquery.
Hint
WHERE NOT EXISTS (SELECT 1 FROM case_suspects cs WHERE cs.suspect_id = s.suspect_id). The inner query refers to s from the outer query, so it runs once per suspect.
Show the solution and expected output
SELECT first_name, last_name
FROM suspects s
WHERE NOT EXISTS (
SELECT 1 FROM case_suspects cs WHERE cs.suspect_id = s.suspect_id
);
Why it works: A correlated subquery depends on the outer row. NOT EXISTS is true when the inner query finds nothing for that suspect, and unlike NOT IN it handles NULLs safely.
Other databases: Identical everywhere.
Expected output (any row order):
| first_name | last_name |
|---|
| Mateus | Albuquerque |
| Priya | Raghavan |
| Grace | Okafor |
| Leo | Martins |
| Marcus | Lee |
| Ryan | Murphy |
| Zoe | Adams |
| Ava | Thompson |
12 rows (first 8 shown)
Solve this case in the 3D game →
5. Top informants Semi-Pro
joingrouphaving · database: Port Evidence Room
Case request: return each informant's name and total_value (quantity × street_value over all seizures their tips led to, any status), only for informants whose tips were worth more than 500, biggest first.
Hint
Chain the joins: informants to seizures to seizure_items to contraband. GROUP BY the informant, SUM(quantity * street_value), then filter the groups with HAVING (WHERE can't see aggregates).
Show the solution and expected output
SELECT i.name, SUM(si.quantity * c.street_value) AS total_value
FROM informants i
JOIN seizures s ON s.informant_id = i.informant_id
JOIN seizure_items si ON si.seizure_id = s.seizure_id
JOIN contraband c ON c.item_id = si.item_id
GROUP BY i.informant_id, i.name
HAVING SUM(si.quantity * c.street_value) > 500
ORDER BY total_value DESC;
Why it works: WHERE filters rows before grouping. HAVING filters groups after. Four tables joined in a chain give one row per item seized, and SUM rolls them up per informant.
Other databases: Identical everywhere. PostgreSQL and SQL Server don't let you use the alias total_value in HAVING; repeating the expression is portable.
Expected output (row order matters):
| name | total_value |
|---|
| Zane Mitchell | 2,592.35 |
| Rachel Kim | 2,434.38 |
| Faisal Rahman | 2,062.97 |
| Derek Shaw | 2,029.5 |
| Ivy Chen | 1,748.87 |
| Quinn Harper | 1,652.4 |
| Megan Foster | 1,567.48 |
| Alicia Moreno | 1,494.5 |
18 rows (first 8 shown)
Solve this case in the 3D game →
6. Marseille only Pro
set ops · database: Port Evidence Room
Case request: which contraband items (name) were seized from tips by at least one informant in Marseille but never from a tip by anyone in Rotterdam? Use a set operation.
Hint
Write one query for items seized from Marseille tips and one for Rotterdam tips (same joins, different port), then put EXCEPT between them.
Show the solution and expected output
SELECT c.name
FROM contraband c
JOIN seizure_items si ON si.item_id = c.item_id
JOIN seizures s ON s.seizure_id = si.seizure_id
JOIN informants i ON i.informant_id = s.informant_id
WHERE i.port = 'Marseille'
EXCEPT
SELECT c.name
FROM contraband c
JOIN seizure_items si ON si.item_id = c.item_id
JOIN seizures s ON s.seizure_id = si.seizure_id
JOIN informants i ON i.informant_id = s.informant_id
WHERE i.port = 'Rotterdam';
Why it works: EXCEPT returns rows from the first query that don't appear in the second, and removes duplicates. UNION stacks results and INTERSECT keeps only the rows in both.
Other databases: Oracle calls it MINUS (EXCEPT works from 21c). MySQL added EXCEPT in 8.0.31. PostgreSQL and SQL Server use EXCEPT.
Expected output (any row order):
| name |
|---|
| Counterfeit Coffee Sets |
| Looted Star Atlases |
| Pirated Speakers |
| Pirated Textbooks |
| Stolen Hammocks |
5 rows
Solve this case in the 3D game →
7. Out-ranking the boss Pro
self join · database: World Watch-list
Case request: find suspects with a bigger bounty than their own boss. Return suspect (first_name), bounty_usd, boss (the boss's first_name) and boss_bounty.
Hint
Join the suspects table to itself: FROM suspects s JOIN suspects b ON b.suspect_id = s.boss_id. Now s is the underling and b is the boss on the same row.
Show the solution and expected output
SELECT s.first_name AS suspect, s.bounty_usd,
b.first_name AS boss, b.bounty_usd AS boss_bounty
FROM suspects s
JOIN suspects b ON b.suspect_id = s.boss_id
WHERE s.bounty_usd > b.bounty_usd;
Why it works: A self-join treats one table as two, with different aliases. Each row pairs a suspect with their boss, so you can compare the two bounties side by side.
Other databases: Identical everywhere.
Expected output (any row order):
| suspect | bounty_usd | boss | boss_bounty |
|---|
| Victor | 158,000 | Hana | 155,000 |
| Wendy | 161,000 | Hana | 155,000 |
2 rows
Solve this case in the 3D game →
More practice: all 26 SQL exercises · window functions practice.