Problem Solving · Episode 27

Top N per group

window functionsranking
The story

Marketing wants a “shop by category” carousel on the homepage: “Top 3 products, but per category — every category needs to show something.”

Harry

Top N by revenue, we just did this. Join to orders, sum, order by revenue, limit 3.

Hermione

Try it. Then check which categories show up.

Harry's attempt
query.sql
SELECT p.name, p.category, SUM(o.amount) AS revenue
FROM products p
JOIN orders o ON o.product_id = p.id
WHERE o.status = 'completed'
GROUP BY p.id, p.name, p.category
ORDER BY revenue DESC
LIMIT 3;
Result3 rows
namecategoryrevenue
Pulse SmartwatchWearables399.98
Aurora EarbudsAudio239.97
Nova KeyboardComputing178

Marketing: “Where’s Accessories? Where’s Home? We said every category.”

The concept

LIMIT 3 did exactly what it was told: take the top 3 rows, full stop. It has no idea categories exist — it just sorted every product by revenue and sliced off the top of the whole list. Two categories happened to own all three spots, so the rest got nothing.

What you actually want is a limit that resets for every group. SQL doesn’t have a “LIMIT per category” keyword — but you’ve already met the tool that numbers rows within a group and starts over at the next one: PARTITION BY, from window functions.

Rank within each group, restarting the count at every group.

Build the query

First, number every product within its own category, ordered by revenue — the same ROW_NUMBER() from the ranking lesson, just partitioned this time:

query.sql
SELECT
  p.name,
  p.category,
  SUM(o.amount) AS revenue,
  ROW_NUMBER() OVER (
    PARTITION BY p.category
    ORDER BY SUM(o.amount) DESC
  ) AS rn
FROM products p
JOIN orders o ON o.product_id = p.id
WHERE o.status = 'completed'
GROUP BY p.id, p.name, p.category;

rn restarts at 1 for every new category — Accessories gets its own #1, #2, #3, completely independent of Wearables’ #1, #2, #3. Now filter, the same way you’d filter anything else that came from a window function: wrap it and check the outer query.

query.sql
WITH ranked AS (
  SELECT
    p.name,
    p.category,
    SUM(o.amount) AS revenue,
    ROW_NUMBER() OVER (
      PARTITION BY p.category
      ORDER BY SUM(o.amount) DESC
    ) AS rn
  FROM products p
  JOIN orders o ON o.product_id = p.id
  WHERE o.status = 'completed'
  GROUP BY p.id, p.name, p.category
)
SELECT name, category, revenue
FROM ranked
WHERE rn <= 3
ORDER BY category, rn;
Result11 rows
namecategoryrevenue
Comet Charger 65WAccessories74.97
Nova BackpackAccessories64
Comet Charger 20WAccessories44.97
Aurora EarbudsAudio239.97
Nova Speaker MiniAudio99.98
Nova KeyboardComputing178
Orbit WebcamComputing59.99
Nova MouseComputing39
Halo Desk LampHome69
Pulse SmartwatchWearables399.98
Pulse BandWearables29.99

Every category is represented now. Audio, Home, and Wearables only have one or two products with completed orders at all, so “top 3” just returns everything they’ve got — which is correct. A per-group limit that can’t be satisfied isn’t a bug, it’s just an honest answer.

Run it
query.sql

Running…

Try it: What if marketing only has room for the top 2 per category, not 3 — change rn <= 3 to rn <= 2?
experiment.sql

Run your query to see results here.

Now it actually trims something: the Nova Mouse drops out of Computing and the Comet Charger 20W drops out of Accessories, because those two categories genuinely had 3+ products to choose from. Audio, Home, and Wearables are untouched — they never had more than 2 to begin with. This is the difference between the two examples: N=3 only exposed the “missing categories” bug, while N=2 is small enough to also show the pattern actually cutting rows.

Your turn

HR wants the top 2 highest-paid employees in every department, for a compensation review. Return name, department, and salary. Two employees can tie on salary — when that happens, break the tie by whoever was hired first.

Opens in a new window, full width — come back here once you’re done.
Hint 1

Same shape as the products-per-category problem: rank rows within a group, then keep the ones at or above the cutoff rank.

Hint 2

ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC, hire_date ASC) gives every employee a rank inside their own department, with ties broken by seniority.

Hint 3

Wrap that in a CTE (or subquery), then in the outer query: WHERE rn <= 2.

Solution
solution.sql

Running…

Debrief

Engineering has five employees and two salary ties in it — the exact situation where a plain LIMIT would silently pick whichever tied row the database feels like returning first. Breaking the tie explicitly with hire_date ASC makes the ranking deterministic instead of accidental. Different table, same pattern as the product carousel: partition, rank, cut.

One thing to remember

A single LIMIT only ever cuts the whole result once. To cap rows per group, rank rows with a window function partitioned by that group (ROW_NUMBER, RANK, or DENSE_RANK), then filter on the rank in an outer query. Always give ties an explicit tiebreaker, or the “top N” you get back depends on luck.