CTSGSQL CRIME LABS

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

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_namelast_namerole
MateusAlbuquerqueKingpin
DanteMoreauChief Smuggler
PriyaRaghavanChief Forger
TobiasAlvarezCartel Treasurer
GraceOkaforFixer
LeoMartinsDock Boss
HanaSatoCyber Chief
JamalBrooksSenior 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_namelast_namebounty_usd
MateusAlbuquerque245,000
DanteMoreau198,000
PriyaRaghavan176,000
GraceOkafor151,000
HanaSato155,000
JamalBrooks152,000
VictorSilva158,000
WendyZhao161,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_namefirst_seen
Chris2021-03-15
Ben2022-01-10
Sofia2023-06-05
Marcus2024-02-12
Felix2021-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):

nameport
Brandon LewisRotterdam
Carmen DiazSingapore
Gina ParkRotterdam
Ivy ChenSingapore
Kendra EllisRotterdam
Megan FosterSingapore
Olivia GrantRotterdam
Quinn HarperSingapore

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_namelast_namerole
DanteMoreauChief Smuggler
JamalBrooksSenior Smuggler
ElenaPetrovaSenior Smuggler
ChrisYoungSmuggler
AishaKhanSmuggler
BenCarterSmuggler
SofiaRossiJunior Smuggler
MarcusLeeJunior 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):

namestreet_value
Pirated Textbooks22.5
Fake Thermoses24
Bootleg Lamps27.5
Forged First Editions32
Fake Charging Hubs34.5
Fake Board Games36
Smuggled Cookware39.95
Looted Star Atlases45

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_namelast_namebounty_usd
MateusAlbuquerque245,000
DanteMoreau198,000
PriyaRaghavan176,000
WendyZhao161,000
VictorSilva158,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):

suspectstotal_bountyavg_bounty
404,324,000108,100

1 row

Solve this case in the 3D game →

Semi-Pro: joins, grouping and subqueries

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_idheadcount
110
27
35
45
56
65

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

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

nameheadcount
Brazil10
France7
Japan5
Nigeria5
Mexico6
Italy5
Germany0

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_namelast_name
ElenaPetrova
MarcusLee
RyanMurphy
AvaThompson
JackTurner
IanWalsh

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_namebounty_usd
Mateus245,000
Dante198,000
Priya176,000
Tobias142,000
Grace151,000
Hana155,000
Jamal152,000
Elena149,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_namebounty_usdtier
Mateus245,000Most Wanted
Dante198,000Most Wanted
Priya176,000Most Wanted
Tobias142,000Most Wanted
Grace151,000Most Wanted
Leo98,000Wanted
Hana155,000Most Wanted
Jamal152,000Most 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):

monthseizures
2024-015
2024-023
2024-031
2024-044
2024-051
2024-066
2024-071
2024-083

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

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 →

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

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

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_namecountry_idbounty_usdcountry_rank
Dante1198,0001
Jamal1152,0002
Elena1149,0003
Nina1126,0004
Aisha1121,0005
Chris1118,0006
Felix1118,0006
Ben1112,0008

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

countryfirst_namebounty_usd
BrazilDante198,000
FrancePriya176,000
JapanTobias142,000
NigeriaMateus245,000
MexicoLeo98,000
ItalyWendy161,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):

suspectbounty_usdbossboss_bounty
Victor158,000Hana155,000
Wendy161,000Hana155,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_namelevel
Mateus1
Dante2
Grace2
Hana2
Leo2
Priya2
Tobias2
Elena3

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_idfirst_namefirst_seendays_since_prev
1Dante2016-06-13NULL
1Jamal2018-09-10819
1Elena2019-05-06238
1Aisha2020-07-20441
1Nina2020-10-0173
1Chris2021-03-15165
1Felix2021-08-30168
1Ben2022-01-10133

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

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_dateseized_valuerunning_total
2024-01-08329.5329.5
2024-01-30414.5744
2024-04-012681,012
2024-04-29470.981,482.98
2024-05-30520.972,003.95
2024-06-021,1063,109.95
2024-06-191,1354,244.95
2024-07-185234,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 →