How to Rank Rows Per Group With SQL Window Functions
SQL window functions let you rank rows within a group in a single pass. ROW_NUMBER() OVER (PARTITION BY region ORDER BY spend DESC) assigns a rank per region without a subquery per row - here's how to build the full top-N report.
Why correlated subqueries fall apart
The naive approach to "top 3 customers per region" runs a subquery for each row:
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 table once per row - O(n^2) in the worst case. With thousands of customers it becomes painfully slow, and the logic is hard to follow.
ROW_NUMBER() OVER (PARTITION BY ...) - the right tool
Window functions compute 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, ...
within each partition - the partition being your group key.
The two-CTE pattern is the standard structure:
-- Step 1: aggregate per customer first
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
),
-- Step 2: rank within each region
ranked AS (
SELECT
region,
customer_id,
customer_name,
total_spend,
ROW_NUMBER() OVER (
PARTITION BY region
ORDER BY total_spend DESC, customer_id -- tiebreak for determinism
) AS rank
FROM per_customer
)
-- Step 3: keep only the top 3 per region
SELECT region, customer_id, customer_name, total_spend, rank
FROM ranked
WHERE rank <= 3
ORDER BY region, rank;
Why the two-CTE structure is necessary
You cannot filter on a window function alias directly in a WHERE clause - window functions are evaluated after WHERE. Wrapping the ranking step in a CTE (or subquery) and filtering in the outer query is the correct pattern:
-- This DOES NOT WORK - rank is not yet available here:
SELECT ..., ROW_NUMBER() OVER (...) AS rank
FROM per_customer
WHERE rank <= 3; -- ERROR: column "rank" does not exist
ROW_NUMBER vs RANK vs DENSE_RANK
| Function | Ties |
|---|---|
ROW_NUMBER |
Always unique - one row wins arbitrarily |
RANK |
Tied rows share the same rank; next rank skips (1, 1, 3) |
DENSE_RANK |
Tied rows share the same rank; next rank does not skip (1, 1, 2) |
For top-N reports where you want exactly N rows per partition, ROW_NUMBER is
the safest choice. Add a stable tiebreak column (customer_id) in the ORDER BY
to make results deterministic across runs.
Performance
The two-CTE version does one full scan of orders, one aggregation, and one
ranking pass - O(n log n) with an index on customer_id. Modern query planners
(PostgreSQL, Snowflake, BigQuery, DuckDB) all handle this in a single execution
plan, making window function queries significantly faster than correlated
subquery equivalents on large datasets.
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
- Writing a two-CTE pattern: aggregate first, then rank with ROW_NUMBER() OVER (PARTITION BY ...)
- Filtering on window function results using an outer query or CTE
- Choosing between ROW_NUMBER, RANK, and DENSE_RANK for different tie-handling needs
FAQ
How do I get the top N rows per group in SQL?
Use ROW_NUMBER() OVER (PARTITION BY group_col ORDER BY sort_col DESC) in a CTE to rank rows within each group, then filter WHERE rank <= N in an outer query. This is far more efficient than a correlated subquery.
Why can't I use a window function in a WHERE clause?
Window functions are evaluated after WHERE in the SQL order of operations. You must wrap the windowed query in a CTE or subquery and apply the filter in the outer SELECT.
What is the difference between ROW_NUMBER, RANK, and DENSE_RANK?
ROW_NUMBER always assigns unique integers with no ties. RANK shares ranks on ties but skips numbers (1,1,3). DENSE_RANK shares ranks on ties without skipping (1,1,2). For top-N row limits, ROW_NUMBER is usually the right choice.
What is the difference between RANK() and DENSE_RANK()?
Both rank rows within a partition but handle ties differently. RANK() leaves gaps after ties (1, 2, 2, 4); DENSE_RANK() does not (1, 2, 2, 3). ROW_NUMBER() ignores ties and numbers every row uniquely.
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 →