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_name | country_id | bounty_usd | country_avg |
|---|
| Dante | 1 | 198,000 | 126,400 |
| Jamal | 1 | 152,000 | 126,400 |
| Elena | 1 | 149,000 | 126,400 |
| Nina | 1 | 126,000 | 126,400 |
| Aisha | 1 | 121,000 | 126,400 |
| Chris | 1 | 118,000 | 126,400 |
| Felix | 1 | 118,000 | 126,400 |
| Ben | 1 | 112,000 | 126,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_name | country_id | bounty_usd | rank | dense_rank | row_number |
|---|
| Dante | 1 | 198,000 | 1 | 1 | 1 |
| Jamal | 1 | 152,000 | 2 | 2 | 2 |
| Elena | 1 | 149,000 | 3 | 3 | 3 |
| Nina | 1 | 126,000 | 4 | 4 | 4 |
| Aisha | 1 | 121,000 | 5 | 5 | 5 |
| Chris | 1 | 118,000 | 6 | 6 | 6 |
| Felix | 1 | 118,000 | 6 | 6 | 7 |
| Ben | 1 | 112,000 | 8 | 7 | 8 |
| Sofia | 1 | 86,000 | 9 | 8 | 9 |
| Marcus | 1 | 84,000 | 10 | 9 | 10 |
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
| 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
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
| 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
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_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 →
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):
| 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 →
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_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 →
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_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 →
More practice: all 26 SQL exercises (including CTE practice) · SQL JOIN practice.