Top N Per Group
Three ways to get the best row in each group - window function, correlated subquery and lateral join - and how to pick between them.
- SQL Basics
- DDL
- DML
- SELECT
- WHERE
- Functions
- NULL and Logic
- Aggregate Functions
- GROUP BY
- JOIN
- Subqueries
- Set Operations
- CTEs
- Constraints
- Keys
- Relationships
- Database Design
- Normalisation
- Views
- Window Functions
- Advanced SQL
- Procedures and Functions
- Triggers
- Temporary Tables
- Transactions
- Isolation and Locking
- Indexes
- Query Performance
- SQL Security
- SQL Dialects
- Practical SQL
Concept
The most recent order per customer. The highest paid employee per department. The best selling product per category. All the same shape: rank rows inside a group, keep the top N. It is the single most common non trivial SQL question there is.
Method 1: ROW_NUMBER (preferred)
WITH ranked AS (
SELECT o.*,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC, id DESC) AS rn
FROM orders o
)
SELECT customer_id, id AS order_id, order_date, total
FROM ranked
WHERE rn = 1
ORDER BY customer_id;To get the top three instead of the top one, change rn = 1 to rn <= 3. Nothing else changes, which is why this method scales to any N.
Method 2: correlated subquery
SELECT o.customer_id, o.id AS order_id, o.order_date, o.total
FROM orders o
WHERE o.order_date = (SELECT MAX(o2.order_date)
FROM orders o2
WHERE o2.customer_id = o.customer_id)
ORDER BY o.customer_id;Works everywhere, including MySQL 5.7. The catch: if a customer has two orders on the same date, both come back. That is either a bug or the requirement, so decide deliberately.
Method 3: pre aggregate and join
SELECT o.customer_id, o.id AS order_id, o.order_date, o.total
FROM orders o
JOIN (SELECT customer_id, MAX(order_date) AS latest
FROM orders
GROUP BY customer_id) m
ON m.customer_id = o.customer_id AND m.latest = o.order_date
ORDER BY o.customer_id;Same tie behaviour as method 2, but the aggregate runs once rather than once per row, which usually makes it the fastest option on older engines.
Method 4: LATERAL / CROSS APPLY
-- PostgreSQL, MySQL 8.0.14+, Oracle 12c+
SELECT c.name, o.id AS order_id, o.order_date, o.total
FROM customers c
LEFT JOIN LATERAL (SELECT id, order_date, total
FROM orders
WHERE customer_id = c.id
ORDER BY order_date DESC, id DESC
LIMIT 1) o ON TRUE;
-- SQL Server spells it CROSS APPLY / OUTER APPLY
-- OUTER APPLY (SELECT TOP 1 ... ORDER BY ...) oA lateral join lets the subquery reference the outer row, so it can carry its own ORDER BY and LIMIT. It is often the fastest method when the group count is small and each group is large, because the index gives the top row immediately. MariaDB does not support LATERAL.
Comparison
| Method | Handles ties | Top N easily | Works on MySQL 5.7 | Typical performance |
|---|---|---|---|---|
ROW_NUMBER | Picks exactly one | Yes | No | Good; one sort per partition |
| Correlated subquery | Returns all ties | Awkward | Yes | Poor on large tables |
| Pre aggregate + join | Returns all ties | Awkward | Yes | Good |
LATERAL | Picks exactly one | Yes, change LIMIT | No | Often best with a matching index |
Important rules
- Add a unique tie breaker to the window
ORDER BY, or the "top" row is not deterministic. ROW_NUMBERgives exactly one row per group;RANKgives all tied rows.- Rows filtered out by
WHEREare never ranked - filter before, not after. - An index on
(partition_column, order_column DESC)is what makes any of these fast.
Common mistakes
- Writing
SELECT customer_id, MAX(order_date), total FROM orders GROUP BY customer_id- thetotalcomes from an arbitrary row, not from the latest order. This is the classic wrong answer. - Omitting the tie breaker and getting different results on different runs.
- Using
RANKwhere exactly one row per group was required. - Filtering on
rnin the same query level that computes it.
Best practices
- Default to
ROW_NUMBERin a CTE - it reads clearly and generalises to any N. - Always end the window
ORDER BYwith the primary key. - Create a composite index matching the partition and order columns.
- Decide explicitly whether ties should all appear, and choose the function accordingly.
Practice
- Return the two highest paid employees in each department.
- Return each customer's single largest order, breaking ties by order id.
- Explain precisely why the
GROUP BYwithMAX(order_date)and a baretotalcolumn is wrong.