How to Build a Top-N Report With MySQL Window Functions
MySQL window functions (8.0+) let you rank rows within a group in a single pass. ROW_NUMBER() OVER (PARTITION BY region ORDER BY total_spend DESC) numbers each region's customers 1, 2, 3 with no per-row subquery. This project builds the full top-3-customers-per-region report - 9 rows, 3 regions x 3 ranks - the same standard-SQL syntax works on MySQL, PostgreSQL, Snowflake, and BigQuery.
Why the correlated-subquery version falls apart
The classic "top 3 customers per region" report is often written with a subquery that counts how many customers in the same region outspent the current one:
SELECT region, name, total_spend
FROM customer_totals c
WHERE (
SELECT COUNT(*) FROM customer_totals c2
WHERE c2.region = c.region AND c2.total_spend > c.total_spend
) < 3
ORDER BY region, total_spend DESC;
This re-scans the totals once per row - O(n^2) in the worst case - and the intent is buried in the subquery. Window functions replace the whole thing with one declarative ranking pass.
ROW_NUMBER() OVER (PARTITION BY ...)
A window function computes a value across a set of rows related to the current row
without collapsing them the way GROUP BY does. ROW_NUMBER() assigns 1, 2, 3, ...
inside each partition, and the partition is your group key - here, region.
You cannot filter on a window function alias in a WHERE clause, because window
functions are evaluated after WHERE. So the standard shape is a two-CTE pattern:
aggregate first, rank second, then filter in an outer query.
WITH per_customer AS (
SELECT
c.region,
c.id AS customer_id,
c.name AS customer_name,
COALESCE(SUM(o.amount), 0) AS total_spend
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.region, c.id, c.name
),
ranked AS (
SELECT
region,
customer_id,
customer_name,
total_spend,
ROW_NUMBER() OVER (
PARTITION BY region
ORDER BY total_spend DESC, customer_id -- stable tiebreak
) AS rank
FROM per_customer
)
SELECT region, customer_id, customer_name, total_spend, rank
FROM ranked
WHERE rank <= 3
ORDER BY region, rank;
Two things make this correct. LEFT JOIN orders keeps customers who have no orders
(their total_spend becomes 0 via COALESCE), and the customer_id tiebreak in the
ORDER BY makes the ranking deterministic when two customers tie on spend.
Run it and check the output
On the seeded data - 12 customers across the east, west, and central regions - the query returns exactly 9 rows, three per region, ranked 1 to 3:
psql -h localhost -U postgres -d app -f query.sql
The rank-1 rows should be Carol (east, 1000.00), Grace (west, 1500.00), and Judy
(central, 1000.00). If you get more than 9 rows you probably forgot the WHERE rank <= 3
filter; if a region is missing a customer, your JOIN dropped the zero-spend rows.
MySQL, PostgreSQL, and the rest
This is standard SQL:2003 window syntax, so the exact same query runs on MySQL 8.0+,
PostgreSQL, Snowflake, BigQuery, and DuckDB. The only version gotcha is that MySQL
added window functions in 8.0 - on 5.7 and earlier they do not exist, which is the most
common reason ROW_NUMBER() OVER (...) throws a syntax error on MySQL.
ROW_NUMBER vs RANK vs DENSE_RANK
| Function | Ties |
|---|---|
ROW_NUMBER |
Always unique - one tied row wins arbitrarily |
RANK |
Tied rows share a rank, then the next rank skips (1, 1, 3) |
DENSE_RANK |
Tied rows share a rank, next rank does not skip (1, 1, 2) |
For a top-N report where you want exactly N rows per group, ROW_NUMBER is the safe
choice - RANK can return more than N rows when there are ties at the cutoff.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Aggregating with SUM under a GROUP BY, then ranking the result with ROW_NUMBER() OVER (PARTITION BY ...)
- Filtering on a window function using the two-CTE pattern because window functions run after WHERE
- Choosing between ROW_NUMBER, RANK, and DENSE_RANK based on how ties should be handled
FAQ
How do I get the top N rows per group in MySQL?
Use ROW_NUMBER() OVER (PARTITION BY group_col ORDER BY sort_col DESC) in a CTE to number rows within each group, then filter WHERE rank <= N in an outer query. This needs MySQL 8.0 or later, and is far more efficient than a correlated subquery.
Does MySQL support window functions?
Yes, MySQL added window functions in version 8.0. Functions like ROW_NUMBER(), RANK(), DENSE_RANK(), LAG(), and LEAD() with the OVER (PARTITION BY ... ORDER BY ...) syntax are unavailable on MySQL 5.7 and earlier, which is why those queries throw a syntax error on older servers.
Why can't I use a window function in a WHERE clause?
Window functions are evaluated after WHERE in SQL's order of operations, so the rank column does not exist yet when WHERE runs. Wrap the windowed query in a CTE or subquery and put the filter in the outer SELECT instead.
What is the difference between ROW_NUMBER, RANK, and DENSE_RANK?
ROW_NUMBER always assigns unique numbers with no ties. RANK shares a rank on ties but then skips numbers (1, 1, 3). DENSE_RANK shares a rank on ties without skipping (1, 1, 2). For a strict top-N limit, ROW_NUMBER is usually the right pick.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →