CTSGSQL CRIME LABS

SQL window functions practice

Four advanced exercises with worked solutions: ranking within groups, the top row per group, the gap from the previous row and a running total. Every output table is real.

Play SQL Crime Labs free →

Window functions compute a value for every row from a set of related rows (its window) without collapsing the rows the way GROUP BY does. These exercises cover the patterns analysts use most: ranking, top row per group, comparing with the previous row and running totals.

OVER, PARTITION BY and ORDER BY

PARTITION BY splits the rows into groups, and the function restarts in each one. ORDER BY inside OVER sets the order within the group. Unlike GROUP BY, every row stays, so you can show each suspect next to their country's average:

SELECT first_name, country_id, bounty_usd,
       AVG(bounty_usd) OVER (PARTITION BY country_id) AS country_avg
FROM suspects;
first_namecountry_idbounty_usdcountry_avg
Dante1198,000126,400
Jamal1152,000126,400
Elena1149,000126,400
Nina1126,000126,400
Aisha1121,000126,400
Chris1118,000126,400
Felix1118,000126,400
Ben1112,000126,400

17 rows (first 8 shown)

RANK vs DENSE_RANK vs ROW_NUMBER

All three number the rows in order. They differ only on ties: RANK leaves gaps after a tie, DENSE_RANK does not, and ROW_NUMBER never repeats a number. Here they are side by side for the largest country crew:

SELECT first_name, bounty_usd,
       RANK()       OVER w AS rank,
       DENSE_RANK() OVER w AS dense_rank,
       ROW_NUMBER() OVER w AS row_number
FROM suspects
WINDOW w AS (PARTITION BY country_id ORDER BY bounty_usd DESC);
first_namecountry_idbounty_usdrankdense_rankrow_number
Dante1198,000111
Jamal1152,000222
Elena1149,000333
Nina1126,000444
Aisha1121,000555
Chris1118,000666
Felix1118,000667
Ben1112,000878
Sofia186,000989
Marcus184,00010910

10 rows

Use ROW_NUMBER when you need exactly one row per group (the top target per country), and RANK when tied values should share a place.

LAG: comparing a row with the previous one

LAG(column) reads the value from the previous row in the window, which turns gaps, changes and day-over-day differences into one expression. See Gap between sightings.

Running totals with SUM() OVER

SUM(x) OVER (ORDER BY date) adds each row to everything before it. Combined with a CTE that totals each day first, it builds the running seized value in Running seized value.

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

Port Evidence Room database

Informants across five ports, the contraband in the vault, and every seizure their tips led to.

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

Window function exercises with solutions

1. 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 →

2. 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 →

3. 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 →

4. 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 →

More practice: all 26 SQL exercises (including CTE practice) · SQL JOIN practice.