SQL practice exercises with solutions
26 practice problems from a fictional crime lab, each with the schema, a hint, a worked solution, an explanation and the expected output. Beginner to advanced, all free.
Play SQL Crime Labs free →
Each exercise is a case request from the lab. Read the schema, write your query, then open the solution to check it against the worked answer, an explanation and the expected output table. Want it interactive? Every case links into the 3D game, where your query runs on real SQLite in the browser and you get three tries and a clue.
Jump to a topic
GROUP BY and HAVING practice
Aggregation cases: Suspects by country, Every country counts, Seizures per month, Top informants. HAVING filters groups after they are counted: Top informants.
CTE practice
Common table expressions (WITH ... AS) name a step so the next one can build on it: top row per group in Top target per country, a recursive chain of command in Chain of command and a running total in Running seized value.
The two practice databases
All the data is fictional. Open a table to see its columns and sample rows.
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
Rookie: SQL practice for beginners
Start here if you are new to SQL. These nine cases use one table at a time and build up SELECT, WHERE, AND/OR, IN, LIKE, ORDER BY, LIMIT, DISTINCT and your first aggregates. You only need to know what a table and a column are.
1. Pull the watch-list Rookie
select · database: World Watch-list
Case request: the task force wants a printed watch-list of every suspect on file. Pull each suspect's first_name, last_name and role.
Hint
You need every row, so no WHERE clause. Name the three columns after SELECT, separated by commas, then say which table with FROM suspects.
Show the solution and expected output
SELECT first_name, last_name, role
FROM suspects;
Why it works: SELECT picks the columns, FROM picks the table. With no WHERE, every row comes back, which is why all 40 suspects stepped up to the evidence table.
Other databases: Identical in PostgreSQL, MySQL, SQL Server and Oracle.
Expected output (any row order):
| first_name | last_name | role |
|---|
| Mateus | Albuquerque | Kingpin |
| Dante | Moreau | Chief Smuggler |
| Priya | Raghavan | Chief Forger |
| Tobias | Alvarez | Cartel Treasurer |
| Grace | Okafor | Fixer |
| Leo | Martins | Dock Boss |
| Hana | Sato | Cyber Chief |
| Jamal | Brooks | Senior Smuggler |
40 rows (first 8 shown)
Solve this case in the 3D game →
2. Big bounties Rookie
where · database: World Watch-list
Case request: which suspects carry a bounty of more than 150,000? Return first_name, last_name and bounty_usd.
Hint
Filter rows with WHERE bounty_usd > 150000. Numbers don't take quotes or commas.
Show the solution and expected output
SELECT first_name, last_name, bounty_usd
FROM suspects
WHERE bounty_usd > 150000;
Why it works: WHERE keeps only the rows where the condition is true. The suspects who stayed dim are the rows the filter threw away.
Other databases: Identical everywhere. Never write 150,000 with a comma in SQL; the comma separates columns.
Expected output (any row order):
| first_name | last_name | bounty_usd |
|---|
| Mateus | Albuquerque | 245,000 |
| Dante | Moreau | 198,000 |
| Priya | Raghavan | 176,000 |
| Grace | Okafor | 151,000 |
| Hana | Sato | 155,000 |
| Jamal | Brooks | 152,000 |
| Victor | Silva | 158,000 |
| Wendy | Zhao | 161,000 |
8 rows
Solve this case in the 3D game →
3. New faces in Brazil Rookie
whereand · database: World Watch-list
Case request: find suspects in country 1 (Brazil) who first appeared on or after 2021-01-01. Return first_name and first_seen.
Hint
Two conditions joined with AND: country_id = 1 AND first_seen >= '2021-01-01'. Dates are stored as text here, so the date needs quotes.
Show the solution and expected output
SELECT first_name, first_seen
FROM suspects
WHERE country_id = 1
AND first_seen >= '2021-01-01';
Why it works: AND needs both conditions to be true. ISO dates (YYYY-MM-DD) sort the same way as text and as dates, so >= works on them.
Other databases: SQLite stores dates as text. PostgreSQL/MySQL/SQL Server have real DATE types but accept '2021-01-01' the same way. Oracle prefers DATE '2021-01-01'.
Expected output (any row order):
| first_name | first_seen |
|---|
| Chris | 2021-03-15 |
| Ben | 2022-01-10 |
| Sofia | 2023-06-05 |
| Marcus | 2024-02-12 |
| Felix | 2021-08-30 |
5 rows
Solve this case in the 3D game →
4. Two ports Rookie
wherein · database: Port Evidence Room
Case request: the port task force is interviewing informants in Singapore and Rotterdam. List the name and port of every informant in either port.
Hint
Use WHERE port IN ('Singapore', 'Rotterdam'), or two conditions joined with OR. Text values need single quotes.
Show the solution and expected output
SELECT name, port
FROM informants
WHERE port IN ('Singapore', 'Rotterdam');
Why it works: IN (...) is shorthand for port = 'Singapore' OR port = 'Rotterdam'. It reads better as the list grows.
Other databases: Identical everywhere. Text comparison is case-sensitive in PostgreSQL and Oracle, case-insensitive by default in MySQL and SQL Server.
Expected output (any row order):
| name | port |
|---|
| Brandon Lewis | Rotterdam |
| Carmen Diaz | Singapore |
| Gina Park | Rotterdam |
| Ivy Chen | Singapore |
| Kendra Ellis | Rotterdam |
| Megan Foster | Singapore |
| Olivia Grant | Rotterdam |
| Quinn Harper | Singapore |
13 rows (first 8 shown)
Solve this case in the 3D game →
5. Smuggler sweep Rookie
wherelike · database: World Watch-list
Case request: find everyone whose role contains the word Smuggler (Senior Smuggler, Junior Smuggler, Chief Smuggler...). Return first_name, last_name and role.
Hint
LIKE matches patterns. % stands for any run of characters, so '%Smuggler%' means Smuggler anywhere in the role.
Show the solution and expected output
SELECT first_name, last_name, role
FROM suspects
WHERE role LIKE '%Smuggler%';
Why it works: LIKE '%Smuggler%' matches Smuggler with anything before or after it. That catches Chief Smuggler, Senior Smuggler and Junior Smuggler along with plain Smuggler.
Other databases: LIKE is case-insensitive in SQLite and MySQL but case-sensitive in PostgreSQL (use ILIKE there) and in Oracle.
Expected output (any row order):
| first_name | last_name | role |
|---|
| Dante | Moreau | Chief Smuggler |
| Jamal | Brooks | Senior Smuggler |
| Elena | Petrova | Senior Smuggler |
| Chris | Young | Smuggler |
| Aisha | Khan | Smuggler |
| Ben | Carter | Smuggler |
| Sofia | Rossi | Junior Smuggler |
| Marcus | Lee | Junior Smuggler |
9 rows (first 8 shown)
Solve this case in the 3D game →
6. Mid-value evidence Rookie
betweenorder · database: Port Evidence Room
Case request: list the name and street_value of contraband worth between 20 and 60 (inclusive), cheapest first.
Hint
WHERE street_value BETWEEN 20 AND 60 includes both ends. Then ORDER BY street_value sorts ascending (cheapest first) by default.
Show the solution and expected output
SELECT name, street_value
FROM contraband
WHERE street_value BETWEEN 20 AND 60
ORDER BY street_value;
Why it works: BETWEEN a AND b is the same as >= a AND <= b. ORDER BY sorts ascending unless you add DESC. Without ORDER BY, SQL doesn't promise any particular order.
Other databases: Identical everywhere.
Expected output (row order matters):
| name | street_value |
|---|
| Pirated Textbooks | 22.5 |
| Fake Thermoses | 24 |
| Bootleg Lamps | 27.5 |
| Forged First Editions | 32 |
| Fake Charging Hubs | 34.5 |
| Fake Board Games | 36 |
| Smuggled Cookware | 39.95 |
| Looted Star Atlases | 45 |
12 rows (first 8 shown)
Solve this case in the 3D game →
7. Most wanted five Rookie
orderlimit · database: World Watch-list
Case request: show the 5 highest bounties on file: first_name, last_name, bounty_usd, highest first.
Hint
Sort with ORDER BY bounty_usd DESC so the biggest bounty comes first, then keep the first five rows with LIMIT 5.
Show the solution and expected output
SELECT first_name, last_name, bounty_usd
FROM suspects
ORDER BY bounty_usd DESC
LIMIT 5;
Why it works: ORDER BY ... DESC puts the biggest values first and LIMIT cuts the list off. LIMIT applies after sorting, which is why you get the top five and not five random rows.
Other databases: PostgreSQL and MySQL use LIMIT 5. SQL Server uses SELECT TOP 5 ... and Oracle/standard SQL use FETCH FIRST 5 ROWS ONLY.
Expected output (row order matters):
| first_name | last_name | bounty_usd |
|---|
| Mateus | Albuquerque | 245,000 |
| Dante | Moreau | 198,000 |
| Priya | Raghavan | 176,000 |
| Wendy | Zhao | 161,000 |
| Victor | Silva | 158,000 |
5 rows
Solve this case in the 3D game →
8. Which ports? Rookie
distinctorder · database: Port Evidence Room
Case request: which ports do our informants work from? Return each port once, alphabetically.
Hint
SELECT DISTINCT port removes the duplicates. Add ORDER BY port for A to Z.
Show the solution and expected output
SELECT DISTINCT port
FROM informants
ORDER BY port;
Why it works: DISTINCT collapses identical rows into one. Thirty informants work out of only five ports.
Other databases: Identical everywhere.
Expected output (row order matters):
| port |
|---|
| Lagos |
| Marseille |
| Rotterdam |
| Santos |
| Singapore |
5 rows
Solve this case in the 3D game →
9. Bounty snapshot Rookie
aggregate · database: World Watch-list
Case request: the director wants three numbers in one row: suspects (how many are on file), total_bounty (sum of all bounties), and avg_bounty (average bounty, rounded to a whole number).
Hint
Aggregates squash many rows into one: COUNT(*), SUM(bounty_usd), ROUND(AVG(bounty_usd)). Name each one with AS.
Show the solution and expected output
SELECT COUNT(*) AS suspects,
SUM(bounty_usd) AS total_bounty,
ROUND(AVG(bounty_usd)) AS avg_bounty
FROM suspects;
Why it works: Aggregate functions summarize the whole table into a single row. AS gives each result column a readable name.
Other databases: ROUND(x) works in all major databases. SQL Server needs ROUND(x, 0), and its AVG of an integer column returns an integer, so cast to DECIMAL first when you need the exact average.
Expected output (any row order):
| suspects | total_bounty | avg_bounty |
|---|
| 40 | 4,324,000 | 108,100 |
1 row
Solve this case in the 3D game →
Grouping, joins and the questions that trip people up: GROUP BY with HAVING, INNER and LEFT JOIN, NULL checks, subqueries, CASE and dates.
10. Suspects by country Semi-Pro
groupnull · database: World Watch-list
Case request: for each country_id, return the country_id and a headcount of suspects. Two hired guns have no country on file (country_id is NULL); leave them out.
Hint
GROUP BY country_id makes one row per country; COUNT(*) counts the rows in each group. Filter the stateless suspects first with WHERE country_id IS NOT NULL.
Show the solution and expected output
SELECT country_id, COUNT(*) AS headcount
FROM suspects
WHERE country_id IS NOT NULL
GROUP BY country_id;
Why it works: GROUP BY splits the rows into buckets and the aggregate runs once per bucket. Each tower is one bucket. NULL is never equal to anything, so you test it with IS NULL / IS NOT NULL.
Other databases: Identical everywhere.
Expected output (any row order):
| country_id | headcount |
|---|
| 1 | 10 |
| 2 | 7 |
| 3 | 5 |
| 4 | 5 |
| 5 | 6 |
| 6 | 5 |
6 rows
Solve this case in the 3D game →
11. 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 →
12. 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 →
13. No phone intercepted Semi-Pro
null · database: World Watch-list
Case request: surveillance needs to chase suspects with no phone number on file. Return their first_name and last_name.
Hint
phone = NULL never matches anything. Use WHERE phone IS NULL.
Show the solution and expected output
SELECT first_name, last_name
FROM suspects
WHERE phone IS NULL;
Why it works: NULL means unknown, so NULL = NULL isn't true; it's unknown too. IS NULL is the only test that works.
Other databases: Identical everywhere. In Oracle an empty string '' also counts as NULL; elsewhere it doesn't.
Expected output (any row order):
| first_name | last_name |
|---|
| Elena | Petrova |
| Marcus | Lee |
| Ryan | Murphy |
| Ava | Thompson |
| Jack | Turner |
| Ian | Walsh |
6 rows
Solve this case in the 3D game →
14. Above average bounty Semi-Pro
subquery · database: World Watch-list
Case request: who carries a bounty higher than the average bounty on file? Return first_name and bounty_usd.
Hint
You can't use AVG() directly in WHERE. Put it in a subquery: WHERE bounty_usd > (SELECT AVG(bounty_usd) FROM suspects).
Show the solution and expected output
SELECT first_name, bounty_usd
FROM suspects
WHERE bounty_usd > (SELECT AVG(bounty_usd) FROM suspects);
Why it works: A scalar subquery returns a single value, and you can use it anywhere a number would go. It runs first, then the outer query compares each bounty against it.
Other databases: Identical everywhere.
Expected output (any row order):
| first_name | bounty_usd |
|---|
| Mateus | 245,000 |
| Dante | 198,000 |
| Priya | 176,000 |
| Tobias | 142,000 |
| Grace | 151,000 |
| Hana | 155,000 |
| Jamal | 152,000 |
| Elena | 149,000 |
17 rows (first 8 shown)
Solve this case in the 3D game →
15. Threat tiers Semi-Pro
case · database: World Watch-list
Case request: tag every suspect. Return first_name, bounty_usd and tier: 'Most Wanted' for 130,000 and up, 'Wanted' for 80,000 and up, otherwise 'Watch'.
Hint
CASE WHEN bounty_usd >= 130000 THEN 'Most Wanted' WHEN bounty_usd >= 80000 THEN 'Wanted' ELSE 'Watch' END AS tier. The first true WHEN wins, so check the biggest threshold first.
Show the solution and expected output
SELECT first_name, bounty_usd,
CASE WHEN bounty_usd >= 130000 THEN 'Most Wanted'
WHEN bounty_usd >= 80000 THEN 'Wanted'
ELSE 'Watch' END AS tier
FROM suspects;
Why it works: CASE is SQL's if/else. It checks the WHEN conditions top to bottom and stops at the first true one. That's why the order of the thresholds matters.
Other databases: Identical everywhere. SQL Server also has IIF() and MySQL has IF(); CASE works in all of them.
Expected output (any row order):
| first_name | bounty_usd | tier |
|---|
| Mateus | 245,000 | Most Wanted |
| Dante | 198,000 | Most Wanted |
| Priya | 176,000 | Most Wanted |
| Tobias | 142,000 | Most Wanted |
| Grace | 151,000 | Most Wanted |
| Leo | 98,000 | Wanted |
| Hana | 155,000 | Most Wanted |
| Jamal | 152,000 | Most Wanted |
40 rows (first 8 shown)
Solve this case in the 3D game →
16. Seizures per month Semi-Pro
dategrouporder · database: Port Evidence Room
Case request: for seizures made in 2024, return the month as text like 2024-03 and the number of seizures that month (seizures), in calendar order.
Hint
In SQLite, strftime('%Y-%m', seized_date) turns a date into '2024-03'. Filter 2024 with WHERE, GROUP BY that month, ORDER BY it.
Show the solution and expected output
SELECT strftime('%Y-%m', seized_date) AS month, COUNT(*) AS seizures
FROM seizures
WHERE seized_date >= '2024-01-01' AND seized_date < '2025-01-01'
GROUP BY month
ORDER BY month;
Why it works: You can group by a computed value. The month string sorts correctly because the year comes first and the month is zero-padded.
Other databases: PostgreSQL: to_char(seized_date, 'YYYY-MM'). MySQL: DATE_FORMAT(seized_date, '%Y-%m'). SQL Server: FORMAT(seized_date, 'yyyy-MM'). Oracle: TO_CHAR(seized_date, 'YYYY-MM').
Expected output (row order matters):
| month | seizures |
|---|
| 2024-01 | 5 |
| 2024-02 | 3 |
| 2024-03 | 1 |
| 2024-04 | 4 |
| 2024-05 | 1 |
| 2024-06 | 6 |
| 2024-07 | 1 |
| 2024-08 | 3 |
12 rows (first 8 shown)
Solve this case in the 3D game →
17. 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 →
18. 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 →
Pro: SQL challenges
Challenge problems for analysts and job seekers: CTEs, window functions, self-joins, a recursive query and set operations.
19. Rank the crew Pro
window · database: World Watch-list
Case request: within each country, rank suspects by bounty (highest = 1, ties share a rank). Return first_name, country_id, bounty_usd and country_rank. Skip suspects with no country.
Hint
A window function: RANK() OVER (PARTITION BY country_id ORDER BY bounty_usd DESC). PARTITION BY restarts the ranking for each country.
Show the solution and expected output
SELECT first_name, country_id, bounty_usd,
RANK() OVER (PARTITION BY country_id ORDER BY bounty_usd DESC) AS country_rank
FROM suspects
WHERE country_id IS NOT NULL;
Why it works: Window functions compute across related rows without collapsing them like GROUP BY does. RANK gives ties the same number (Chris and Felix both carry 118,000) and then skips the next one.
Other databases: Window functions work in PostgreSQL, SQL Server, Oracle, MySQL 8+ and SQLite 3.25+. MySQL 5.7 doesn't have them.
Expected output (any row order):
| first_name | country_id | bounty_usd | country_rank |
|---|
| Dante | 1 | 198,000 | 1 |
| Jamal | 1 | 152,000 | 2 |
| Elena | 1 | 149,000 | 3 |
| Nina | 1 | 126,000 | 4 |
| Aisha | 1 | 121,000 | 5 |
| Chris | 1 | 118,000 | 6 |
| Felix | 1 | 118,000 | 6 |
| Ben | 1 | 112,000 | 8 |
38 rows (first 8 shown)
Solve this case in the 3D game →
20. Top target per country Pro
ctewindowtop n · database: World Watch-list
Case request: find the single highest bounty in each country. Return the country name (as country), first_name and bounty_usd.
Hint
Number the rows per country with ROW_NUMBER() OVER (PARTITION BY country_id ORDER BY bounty_usd DESC) inside a CTE (WITH ranked AS (...)), then keep WHERE rn = 1. You can't filter on a window function in the same SELECT.
Show the solution and expected output
WITH ranked AS (
SELECT s.first_name, s.bounty_usd, c.name AS country,
ROW_NUMBER() OVER (PARTITION BY s.country_id ORDER BY s.bounty_usd DESC) AS rn
FROM suspects s
JOIN countries c ON c.country_id = s.country_id
)
SELECT country, first_name, bounty_usd
FROM ranked
WHERE rn = 1;
Why it works: This is the top-N-per-group pattern. Window results only exist after WHERE runs, so you compute them in a CTE or subquery and filter on the outside.
Other databases: Same in PostgreSQL, SQL Server, Oracle and MySQL 8+. PostgreSQL also has DISTINCT ON; Snowflake/BigQuery/DuckDB have QUALIFY.
Expected output (any row order):
| country | first_name | bounty_usd |
|---|
| Brazil | Dante | 198,000 |
| France | Priya | 176,000 |
| Japan | Tobias | 142,000 |
| Nigeria | Mateus | 245,000 |
| Mexico | Leo | 98,000 |
| Italy | Wendy | 161,000 |
6 rows
Solve this case in the 3D game →
21. 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 →
22. Chain of command Pro
recursivecte · database: World Watch-list
Case request: map the organization. Return every suspect's first_name and level, where the kingpin (no boss) is level 1, their direct reports are level 2, and so on.
Hint
WITH RECURSIVE chain AS ( start with the kingpin: SELECT ..., 1 AS level FROM suspects WHERE boss_id IS NULL UNION ALL then join suspects to chain ON s.boss_id = c.suspect_id with c.level + 1 ).
Show the solution and expected output
WITH RECURSIVE chain AS (
SELECT suspect_id, first_name, 1 AS level
FROM suspects WHERE boss_id IS NULL
UNION ALL
SELECT s.suspect_id, s.first_name, c.level + 1
FROM suspects s
JOIN chain c ON s.boss_id = c.suspect_id
)
SELECT first_name, level FROM chain;
Why it works: A recursive CTE has a starting query (the anchor) and a step that joins back to itself. Each pass adds the next layer of the organization. It stops when a pass finds no new rows.
Other databases: PostgreSQL, MySQL 8+ and SQLite need WITH RECURSIVE. SQL Server and Oracle write plain WITH. Oracle also has CONNECT BY.
Expected output (any row order):
| first_name | level |
|---|
| Mateus | 1 |
| Dante | 2 |
| Grace | 2 |
| Hana | 2 |
| Leo | 2 |
| Priya | 2 |
| Tobias | 2 |
| Elena | 3 |
40 rows (first 8 shown)
Solve this case in the 3D game →
23. Gap between sightings Pro
windowlagdate · database: World Watch-list
Case request: for each suspect with a country, return country_id, first_name, first_seen and days_since_prev: days since the previous suspect first appeared in the same country (NULL for the first one).
Hint
LAG(first_seen) OVER (PARTITION BY country_id ORDER BY first_seen) gives the previous suspect's date. In SQLite, subtract dates with julianday(a) - julianday(b).
Show the solution and expected output
SELECT country_id, first_name, first_seen,
julianday(first_seen) - julianday(LAG(first_seen) OVER (PARTITION BY country_id ORDER BY first_seen)) AS days_since_prev
FROM suspects
WHERE country_id IS NOT NULL;
Why it works: LAG looks back one row inside the window. The first row in each country has nothing before it, so LAG returns NULL and the subtraction stays NULL.
Other databases: PostgreSQL: first_seen - LAG(first_seen) OVER (...) on DATE columns. SQL Server: DATEDIFF(day, LAG(...), first_seen). MySQL: DATEDIFF(first_seen, LAG(...)).
Expected output (any row order):
| country_id | first_name | first_seen | days_since_prev |
|---|
| 1 | Dante | 2016-06-13 | NULL |
| 1 | Jamal | 2018-09-10 | 819 |
| 1 | Elena | 2019-05-06 | 238 |
| 1 | Aisha | 2020-07-20 | 441 |
| 1 | Nina | 2020-10-01 | 73 |
| 1 | Chris | 2021-03-15 | 165 |
| 1 | Felix | 2021-08-30 | 168 |
| 1 | Ben | 2022-01-10 | 133 |
38 rows (first 8 shown)
Solve this case in the 3D game →
24. 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 →
25. Running seized value Pro
ctewindowrunning · database: Port Evidence Room
Case request: for confirmed seizures in 2024, return each seized_date, that day's seized_value (quantity × street_value) and a running_total across the year, in date order.
Hint
First a CTE that sums value per seized_date (GROUP BY s.seized_date) for confirmed 2024 seizures. Then SUM(seized_value) OVER (ORDER BY seized_date) adds up everything so far.
Show the solution and expected output
WITH daily AS (
SELECT s.seized_date, SUM(si.quantity * c.street_value) AS seized_value
FROM seizures s
JOIN seizure_items si ON si.seizure_id = s.seizure_id
JOIN contraband c ON c.item_id = si.item_id
WHERE s.status = 'confirmed'
AND s.seized_date >= '2024-01-01' AND s.seized_date < '2025-01-01'
GROUP BY s.seized_date
)
SELECT seized_date, seized_value,
SUM(seized_value) OVER (ORDER BY seized_date) AS running_total
FROM daily
ORDER BY seized_date;
Why it works: SUM() OVER (ORDER BY ...) is a running total: each row adds up every row up to and including itself. Doing the GROUP BY in a CTE first keeps the two steps easy to read.
Other databases: Same in PostgreSQL, SQL Server, Oracle, MySQL 8+. Add ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW to be explicit when dates repeat.
Expected output (row order matters):
| seized_date | seized_value | running_total |
|---|
| 2024-01-08 | 329.5 | 329.5 |
| 2024-01-30 | 414.5 | 744 |
| 2024-04-01 | 268 | 1,012 |
| 2024-04-29 | 470.98 | 1,482.98 |
| 2024-05-30 | 520.97 | 2,003.95 |
| 2024-06-02 | 1,106 | 3,109.95 |
| 2024-06-19 | 1,135 | 4,244.95 |
| 2024-07-18 | 523 | 4,767.95 |
17 rows (first 8 shown)
Solve this case in the 3D game →
26. 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 →