SQL Top N per Group
“The top 3 products in each category“, “the latest order of each customer“, “the two best-paid people per department“: top-N-per-group is one of the most common real-world and interview SQL problems. LIMIT cannot do it, because it applies to the whole result. Window functions solve it in one clear pattern.
- Solve top N per group with
ROW_NUMBERin a CTE - Decide how ties should be handled (
RANKvsROW_NUMBER) - Get the latest or first row per group
- Know the older correlated-subquery approach
Why LIMIT is not enough
SELECT dept_id, first_name, salaryFROM employeesORDER BY salary DESCLIMIT 2;| dept_id | first_name | salary |
|---|---|---|
| NULL | Meera | 250000 |
| 1 | Asha | 125000 |
This returns the two best-paid people in the company, not two per department. We need the numbering to restart for each group.
The pattern
WITH ranked AS ( SELECT t.*, ROW_NUMBER() OVER (PARTITION BY group_col ORDER BY sort_col DESC) AS rn FROM t)SELECT * FROM ranked WHERE rn <= N;Top 2 earners per department
WITH ranked AS ( SELECT dept_id, first_name, salary, ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC, emp_id) AS rn FROM employees WHERE dept_id IS NOT NULL)SELECT dept_id, first_name, salary, rnFROM rankedWHERE rn <= 2ORDER BY dept_id, rn;| dept_id | first_name | salary | rn |
|---|---|---|---|
| 1 | Asha | 125000 | 1 |
| 1 | Rahul | 98000 | 2 |
| 2 | Vikram | 105000 | 1 |
| 2 | Neha | 54000 | 2 |
| 3 | Kavya | 88000 | 1 |
| 3 | Rohan | 45000 | 2 |
| 4 | Sneha | 78000 | 1 |
PARTITION BY dept_idnumbers each department separately.ORDER BY salary DESC, emp_idputs the highest salary first;emp_idbreaks ties so the numbering is repeatable.- The outer query keeps rows numbered 1 and 2 in each department.
- HR has only one employee, so it contributes one row.
Ties: ROW_NUMBER or RANK?
Top 3 salaries in Engineering. Priya and Karan tie for third place:
WITH ranked AS ( SELECT first_name, salary, ROW_NUMBER() OVER (ORDER BY salary DESC, emp_id) AS rn, RANK() OVER (ORDER BY salary DESC) AS rnk FROM employees WHERE dept_id = 1)SELECT first_name, salary, rn, rnk FROM ranked WHERE rnk <= 3;| first_name | salary | rn | rnk |
|---|---|---|---|
| Asha | 125000 | 1 | 1 |
| Rahul | 98000 | 2 | 2 |
| Priya | 72000 | 3 | 3 |
| Karan | 72000 | 4 | 3 |
- Filtering on
rn <= 3would return exactly three people and silently drop Karan. - Filtering on
rnk <= 3returns four, because both tied people are third. - Decide with the business which is right: “exactly N rows” (
ROW_NUMBER) or “everyone in the top N positions” (RANK/DENSE_RANK).
Best-selling product per category
WITH sales AS ( SELECT p.category, p.product_name, SUM(oi.quantity) AS units FROM order_items oi JOIN products p ON p.product_id = oi.product_id GROUP BY p.category, p.product_name),ranked AS ( SELECT sales.*, RANK() OVER (PARTITION BY category ORDER BY units DESC) AS rnk FROM sales)SELECT category, product_name, unitsFROM rankedWHERE rnk = 1ORDER BY category;| category | product_name | units |
|---|---|---|
| Electronics | Mechanical Keyboard | 3 |
| Furniture | Office Chair | 3 |
| Furniture | Desk Lamp | 3 |
| Stationery | Notebook Pack | 18 |
salestotals the units sold per product.rankedranks products inside each category by units.- Keeping
rnk = 1returns each category’s best seller, and both products if two tie.
Latest order per customer
WITH ranked AS ( SELECT o.*, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) AS rn FROM orders o)SELECT customer_id, order_id, order_date, statusFROM rankedWHERE rn = 1ORDER BY customer_id;| customer_id | order_id | order_date | status |
|---|---|---|---|
| 1 | 1008 | 2025-04-28 | Pending |
| 2 | 1007 | 2025-04-10 | Delivered |
| 3 | 1009 | 2025-05-15 | Delivered |
| 4 | 1005 | 2025-03-08 | Delivered |
| 5 | 1010 | 2025-06-01 | Delivered |
The pre-MySQL 8 way
Without window functions, top-1 per group used a correlated subquery or a join to the group maximum (Correlated Subqueries). Top N > 1 needed a counting subquery:
SELECT e.dept_id, e.first_name, e.salaryFROM employees eWHERE (SELECT COUNT(*) FROM employees x WHERE x.dept_id = e.dept_id AND x.salary > e.salary) < 2 AND e.dept_id IS NOT NULLORDER BY e.dept_id, e.salary DESC;| dept_id | first_name | salary |
|---|---|---|
| 1 | Asha | 125000 |
| 1 | Rahul | 98000 |
| 2 | Vikram | 105000 |
| 2 | Neha | 54000 |
| 3 | Kavya | 88000 |
| 3 | Rohan | 45000 |
| 4 | Sneha | 78000 |
- For each employee, count colleagues in the same department who earn more.
- Fewer than 2 such colleagues means the employee is in the top 2.
- It works, but it is harder to read and slower than the window-function pattern.
Exercise: Two cheapest products per category
List the two cheapest products of each category.
| category | product_name | price |
|---|---|---|
| Electronics | Wireless Mouse | 799 |
| Electronics | USB-C Hub | 1999 |
| Furniture | Desk Lamp | 1499 |
| Furniture | Monitor Arm | 2799 |
| Stationery | Gel Pens | 199 |
| Stationery | Notebook Pack | 249 |
Hint 1
ROW_NUMBER() OVER (PARTITION BY category ORDER BY price) and keep rn <= 2.
WITH ranked AS ( SELECT category, product_name, price, ROW_NUMBER() OVER (PARTITION BY category ORDER BY price, product_id) AS rn FROM products)SELECT category, product_name, priceFROM rankedWHERE rn <= 2ORDER BY category, price;| category | product_name | price |
|---|---|---|
| Electronics | Wireless Mouse | 799 |
| Electronics | USB-C Hub | 1999 |
| Furniture | Desk Lamp | 1499 |
| Furniture | Monitor Arm | 2799 |
| Stationery | Gel Pens | 199 |
| Stationery | Notebook Pack | 249 |
Quick quiz
1. Why can’t ORDER BY ... LIMIT 3 give the top 3 per group?
Numbering must restart per group, which needs PARTITION BY.
2. Which function returns everyone tied in the top N positions?
Tied rows share the same rank.
3. Why add emp_id to the window’s ORDER BY?
Without a tie-breaker, tied rows can be numbered differently on each run.
Top N per group FAQ
How do I select the top N rows per group in SQL?
Number rows within each group with ROW_NUMBER() OVER (PARTITION BY group ORDER BY value DESC) in a CTE, then keep the rows where the number is at most N.
How do I get the latest record for each group?
Use ROW_NUMBER() OVER (PARTITION BY group ORDER BY date DESC) and keep row number 1.
How do I handle ties in top N queries?
Use ROW_NUMBER with a tie-breaker for exactly N rows, or RANK / DENSE_RANK to include every row tied at the cut-off.