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.

Data Engineersqlmysqlwindow-functions

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

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

Rank Rows Per Group With SQL Window FunctionsData projectWrite a Top-5 Revenue SQL ReportData projectSpeed Up a Slow SQL Report QueryData projectData roadmapStep by step to hiredData interview questionsSTAR answersAll Data projectsProjects hub

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 →