CTSGSQL CRIME LABS

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;
rows_returned
38

1 row

SELECT COUNT(*) FROM suspects s
LEFT JOIN countries c ON c.country_id = s.country_id;
rows_returned
40

1 row

The extra rows are the suspects with no country. The LEFT JOIN keeps them with a NULL country:

first_namecountry_idcountry
IanNULLNULL
KaraNULLNULL

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;
namecount_starcount_suspects
Germany10
Italy55
Japan55
Nigeria55
Mexico66
France77

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:

suspectsrows_after_joindistinct_suspects
403428

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
steprows
informants30
+ JOIN seizures60
+ JOIN seizure_items148
+ JOIN contraband148

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
columntypekey
suspect_idINTEGERPK
first_nameTEXT
last_nameTEXT
country_idINTEGERFK countries (NULL = stateless)
roleTEXT
bounty_usdINTEGER
first_seenTEXT (YYYY-MM-DD)
boss_idINTEGERFK suspects
phoneTEXT (nullable)
Sample rows
suspect_idfirst_namelast_namecountry_idrolebounty_usdfirst_seenboss_idphone
1MateusAlbuquerque4Kingpin245,0002015-03-02NULL+55 21 5550 0101
2DanteMoreau1Chief Smuggler198,0002016-06-131+33 4 5550 0102
3PriyaRaghavan2Chief Forger176,0002017-01-091+81 6 5550 0103
4TobiasAlvarez3Cartel Treasurer142,0002018-04-231+234 1 5550 0104
5GraceOkafor4Fixer151,0002016-11-071+52 664 5550 0105

5 rows

countries 7 rows · country_id, name, hq_city, region, agents
columntypekey
country_idINTEGERPK
nameTEXT
hq_cityTEXT
regionTEXT
agentsINTEGER
Sample rows
country_idnamehq_cityregionagents
1BrazilRio de JaneiroSouth America1,200
2FranceMarseilleEurope650
3JapanOsakaAsia420
4NigeriaLagosAfrica380
5MexicoTijuanaNorth America300

5 rows

cases 8 rows · case_id, title, country_id, opened, closed, loss_usd
columntypekey
case_idINTEGERPK
titleTEXT
country_idINTEGERFK countries
openedTEXT
closedTEXT (NULL = still open)
loss_usdINTEGER
Sample rows
case_idtitlecountry_idopenedclosedloss_usd
1The Blue Marlin Run12023-01-15NULL450,000
2Vermeer's Shadow62022-06-012024-03-31320,000
3Counterfeit Riviera22023-09-01NULL180,000
4Midnight at the Museum32024-02-012024-08-3195,000
5The Ledger Heist42023-04-102024-01-31140,000

5 rows

case_suspects 34 rows · suspect_id, case_id, sightings
columntypekey
suspect_idINTEGERPK, FK suspects
case_idINTEGERPK, FK cases
sightingsINTEGER
Sample rows
suspect_idcase_idsightings
21120
81640
91580
101710
111690

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
columntypekey
informant_idINTEGERPK
nameTEXT
portTEXT
signed_dateTEXT (YYYY-MM-DD)
reliabilityTEXT (bronze/silver/gold)
Sample rows
informant_idnameportsigned_datereliability
1Alicia MorenoMarseille2022-01-14gold
2Brandon LewisRotterdam2022-03-02silver
3Carmen DiazSingapore2022-04-19bronze
4Derek ShawMarseille2022-05-30silver
5Erin WalshSantos2022-07-11gold

5 rows

contraband 20 rows · item_id, name, category, street_value, units_in_vault
columntypekey
item_idINTEGERPK
nameTEXT
categoryTEXT
street_valueREAL
units_in_vaultINTEGER
Sample rows
item_idnamecategorystreet_valueunits_in_vault
1Cloned PhonesElectronics59.99120
2Pirated SpeakersElectronics8964
3Fake Charging HubsElectronics34.5210
4Stolen MonitorsElectronics32918
5Skimmed KeyboardsElectronics11945

5 rows

seizures 60 rows · seizure_id, informant_id, seized_date, status
columntypekey
seizure_idINTEGERPK
informant_idINTEGERFK informants
seized_dateTEXT (YYYY-MM-DD)
statusTEXT (confirmed/in_court/pending/dismissed)
Sample rows
seizure_idinformant_idseized_datestatus
192023-06-01in_court
2182023-06-17in_court
3262023-06-19confirmed
4182023-06-23pending
5162023-08-01confirmed

5 rows

seizure_items 148 rows · seizure_id, item_id, quantity
columntypekey
seizure_idINTEGERPK, FK seizures
item_idINTEGERPK, FK contraband
quantityINTEGER
Sample rows
seizure_iditem_idquantity
1112
172
143
2153
251

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_namelast_namecountry
MateusAlbuquerqueNigeria
DanteMoreauBrazil
PriyaRaghavanFrance
TobiasAlvarezJapan
GraceOkaforNigeria
LeoMartinsMexico
HanaSatoItaly
JamalBrooksBrazil

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):

nameheadcount
Brazil10
France7
Japan5
Nigeria5
Mexico6
Italy5
Germany0

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):

nameport
Erin WalshSantos
Sean O'NeilRotterdam
Bianca TorresMarseille
Caleb PriceSantos
Dana BrooksRotterdam
Eli NavarroLagos

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_namelast_name
MateusAlbuquerque
PriyaRaghavan
GraceOkafor
LeoMartins
MarcusLee
RyanMurphy
ZoeAdams
AvaThompson

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):

nametotal_value
Zane Mitchell2,592.35
Rachel Kim2,434.38
Faisal Rahman2,062.97
Derek Shaw2,029.5
Ivy Chen1,748.87
Quinn Harper1,652.4
Megan Foster1,567.48
Alicia Moreno1,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):

suspectbounty_usdbossboss_bounty
Victor158,000Hana155,000
Wendy161,000Hana155,000

2 rows

Solve this case in the 3D game →

More practice: all 26 SQL exercises · window functions practice.