Get the Top N Rows per Group in pandas
"The top three products in each region" is one of the most requested views in sales reporting, and one of the easiest to get subtly wrong. Excel users build it with LARGE and INDEX/MATCH, or a pivot table filtered to the top 10 with the "Top 10" value filter per row field. In pandas it is a sort and a groupby().head() — but the answer depends on three decisions that the one-liner makes silently: what happens to ties at the cut-off, whether you rank rows or totals, and whether the rest of the data disappears or is shown as "Other". This page, part of Creating Pivot Tables from Excel Data with Pandas, makes each decision explicit.
Prerequisites
pip install pandas openpyxl
sales.xlsx has ten rows across three regions. North has a tie on 700 between Gadgets and Gizmos; South lists Widgets twice, as two separate orders; East has a single product.
Step 1 — Sort, then take the first N of each group
Sort by the group ascending and the value descending, so each group's largest rows come first, then keep the first N rows of every group:
import pandas as pd
sales = pd.read_excel("sales.xlsx")
top2 = (sales.sort_values(["region", "sales"], ascending=[True, False])
.groupby("region")
.head(2))
print(top2.to_string(index=False))
head(2) keeps at most two rows per region: East keeps its only row, North keeps Widgets and Gadgets, South keeps Gadgets and one of its Widgets rows. Unlike groupby().nlargest(), head keeps every column and returns an ordinary DataFrame, which makes it the most convenient form for reports. Groups smaller than N simply return all their rows.
Step 2 — Notice what happened to the tie
North's Gadgets and Gizmos both sold 700, and head(2) kept Gadgets and dropped Gizmos. Nothing in the data justifies that: the sort put Gadgets first because it happened to come first in the file. For a leaderboard, arbitrary tie-breaking is a problem — the same data sorted differently next week produces a different "top two". Either break ties deliberately with a second sort key, or include every tied row:
sales["rank"] = sales.groupby("region")["sales"].rank(method="min", ascending=False)
with_ties = sales[sales["rank"] <= 2].sort_values(["region", "rank"])
print(with_ties.to_string(index=False))
rank(method="min") gives tied values the same, lowest rank — both 700s are rank 2 — so filtering on rank <= 2 keeps North's top three rows. This is how Excel's own "Top 10" filter behaves, and the same rule as RANK.EQ. RANK and PERCENTILE Formulas in pandas compares the ranking methods.
Step 3 — Break ties with a second key when exactly N are needed
Sometimes the report must show exactly N rows — a fixed-size table on a dashboard. Then add a second sort key that means something: more units, more recent, alphabetical as a last resort. Stable ordering makes the output reproducible:
exact = (sales.sort_values(["region", "sales", "product"], ascending=[True, False, True], kind="stable")
.groupby("region")
.head(2))
print(exact[["region", "product", "sales"]].to_string(index=False))
The tie in North is now decided alphabetically — Gadgets before Gizmos — every time the report runs, whatever order the rows arrive in. Say so in a footnote if readers might wonder why one of two equal products was left out.
Step 4 — Rank totals, not rows, when items repeat
South lists Widgets twice: 400 and 350, two orders. "Top products in South" is a question about products, so the two rows should be combined before ranking. Ranking raw rows put Widgets in the top two with a single 400 order, which understates it and could, with more duplicates, list the same product twice:
totals = sales.groupby(["region", "product"], as_index=False)["sales"].sum()
top_products = (totals.sort_values(["region", "sales", "product"], ascending=[True, False, True])
.groupby("region")
.head(2))
print(top_products.to_string(index=False))
After aggregation, South's Widgets total 750 and rank first, above Gadgets at 650 — a different answer from the row-level ranking. Decide which question the report answers — "largest orders" or "best products" — and aggregate accordingly.
Step 5 — Fold the rest into "Other"
A top-N table drops everything below the cut-off, so its numbers no longer add up to the region's total. Readers comparing it with another report notice immediately. Keep the total intact by relabelling everything outside the top N as "Other" and grouping again:
totals["rank"] = totals.groupby("region")["sales"].rank(method="first", ascending=False)
totals["label"] = totals["product"].where(totals["rank"] <= 2, "Other")
summary = totals.groupby(["region", "label"], as_index=False)["sales"].sum()
print(summary.to_string(index=False))
print(summary.groupby("region")["sales"].sum().to_dict() == sales.groupby("region")["sales"].sum().to_dict())
North shows its top two products and an "Other" line holding the remaining 1,100; every region's lines still sum to the region's total, and the final check confirms it. method="first" gives each product a distinct rank so that exactly two are named per region; use "min" if tied products should both be named.
Lay the leaders out side by side
Dashboards often show the top N as a compact grid — one row per region, one column per rank — rather than a long list. Number the rows within each group with cumcount and pivot:
board = top_products.assign(position=top_products.groupby("region").cumcount() + 1)
board["entry"] = board["product"] + " (" + board["sales"].astype(str) + ")"
leaderboard = board.pivot(index="region", columns="position", values="entry")
leaderboard.columns = [f"#{c}" for c in leaderboard.columns]
print(leaderboard.fillna("").to_string())
East has only one product, so its #2 cell is empty — filled with an empty string for display rather than NaN. Because top_products came from the tie-broken sort in Step 4, the columns are stable from run to run. The grid fits naturally at the top of a summary sheet, with the detailed table further down.
Step 6 — Write one sheet with a block per group
A report usually wants the top N for every group in one place. Write the summary with a blank row between regions, which reads better than one long table:
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "Top products"
ws.append(["Region", "Product", "Sales"])
for region, block in summary.groupby("region"):
for row in block.sort_values("sales", ascending=False).itertuples(index=False):
ws.append([row.region, row.label, row.sales])
ws.append([])
wb.save("top_products.xlsx")
print(ws.max_row, "rows written")
Sorting "Other" to its natural position by value keeps the block readable; if "Other" should always come last, sort by a key that puts it there. Add a Native Excel Pivot Table with Python is the alternative when readers want to change N themselves in Excel.
Common pitfalls
- Forgetting to sort before
head.groupby().head(n)returns the first n rows in the current order, not the largest. - Silent tie-breaking. Include ties with
rank(method="min"), or break them with a meaningful second key. - Ranking rows when items repeat. Aggregate first when the question is about products, customers or reps.
- Top-N tables that do not add up. Add an "Other" line so totals reconcile.
- Ranking before filtering. If the report covers one quarter, filter to that quarter first; ranking the whole year and then filtering keeps products that were top for the year but not in the period.
- Missing group labels. Rows with a blank region are dropped by
groupby; label them first or they never appear in any top list.
Performance notes
Sorting once and taking head per group is fast for millions of rows. groupby().rank() is also vectorised. groupby().nlargest(n) works on a single Series and returns a MultiIndex that needs joining back to the other columns — convenient for quick checks, less so for reports.
Conclusion
Sort by group and value, then groupby().head(n) for the top rows per group. Decide ties deliberately — rank(method="min") <= n to include them, a second sort key to break them — aggregate first when items repeat so you rank totals rather than rows, and fold the rest into an "Other" line so the numbers still add up to each group's total.
Frequently asked questions
What is the simplest way to get the top 3 rows per group? Sort by the group, then by the value in descending order, and take the first three rows of each group with groupby("region").head(3). The sort is what makes the first rows the largest.
How are ties handled? head(n) returns exactly n rows, so a tie at the cut-off is broken by sort order, which can be arbitrary. Use rank(method="min") and keep rows with rank <= n to include every tied row, or add a second sort column to break ties deliberately.
Should I rank rows or totals? Rank whatever the question is about. If a product appears on several rows per region, "top products" means the top summed totals, so aggregate with groupby().sum() first and rank the result.
How do I show the rest as "Other"? Mark the top N rows, replace the label of every other row with "Other", and group again. The totals then still add up to the full amount, which a plain top-N list does not.
Related
- Up one level: Creating Pivot Tables from Excel Data with Pandas — pivots, subtotals and exports.
- Highlight Top and Bottom Values in Excel with openpyxl — marking the top rows in the sheet itself.
- Run a Pareto Analysis on Excel Data with pandas — how many items make up most of the total.
- Calculate Percent of Total per Group in pandas — the share each top item represents.
More on pivots & summaries
- How-toBuild a Customer Churn Report from Subscription Data
- How-toRefresh Excel Pivot Tables and Queries with Python
- How-toAdd a Native Excel Pivot Table with Python
- How-toAdd Subtotals and Grand Totals to a pandas Pivot Table
- How-toBin Values into Ranges Like Excel FREQUENCY with pd.cut
- How-toBuild a Cohort Retention Table from Excel Data