Four rows in no useful order. Watch what each approach returns, and what it does with the tie.
import pandas as pd
frame = pd.DataFrame({
'g': ['n', 'n', 'n', 's'],
'name': ['bea', 'al', 'cy', 'dee'],
'revenue': [10, 30, 30, 5],
})
# head(2) takes the first two rows of each group, as the frame stands:
frame.groupby('g').head(2)['name'].tolist()
# -> ['bea', 'al', 'dee'] bea is first, not best
# Sort first, and the same call becomes 'the best two':
ordered = frame.sort_values(['revenue', 'name'], ascending=[False, True])
ordered['name'].tolist() # -> ['al', 'cy', 'bea', 'dee']
ordered.groupby('g').head(2)['name'].tolist()
# -> ['al', 'cy', 'dee'] and the tie is broken by name
# One sort_values, two keys, two directions — not two chained sorts:
frame.sort_values(['revenue', 'name'], ascending=[False, True])
# nlargest exists too, and is not a drop-in replacement:
frame.nlargest(2, 'revenue')['name'].tolist()
# -> ['al', 'cy'] the global top two, ignoring the groups
frame.groupby('g')['revenue'].nlargest(2)
# a Series with a two-level index (g, original row) of *revenues* —
# the names are not in it, so you would have to go back for them.
- head(n) is positional; it has no idea what 'best' means
It returns the first n rows of each group exactly as they sit in the frame, which is why the sort is not optional. The failure is quiet in the worst way: the answer has the right number of names in it, the right regions, and no sign that they were picked by arrival order. If the frame came from a database with an ORDER BY, it will even look right in testing.
- sort_values takes a list of keys and a list of directions
sort_values(['revenue', 'name'], ascending=[False, True]) is one pass that orders by revenue descending and settles ties alphabetically. It is worth preferring over two chained sorts even when both work, because the intent is on one line — and because chaining only works if the second sort is stable, which is a thing you then have to remember.
- nlargest answers a different question, twice over
frame.nlargest(n, col) is the global top n, not per group. groupby(...)[col].nlargest(n) is per group, but it returns the values with a MultiIndex rather than the rows, so you cannot read a name off it without another lookup. It is the right tool when you want n values out of a very large frame — it does not sort everything — and the wrong one when you want whole rows.
groupby('region').head(limit) gives you `limit` rows per region — of the frame as it stands. Print the frame before you call it and ask whether its first rows are the ones you want.
Sort first: sort_values(['revenue', 'name'], ascending=[False, True]) puts the best first and settles the ties. Then head(limit) per region really is the top ones.
Build the dict of regions before you slice, from frame['region'].unique() — otherwise a limit of 0, or a region whose rows all got filtered, disappears from the answer entirely. Then fill each region in from the grouped slice.
import pandas as pd def top_sellers(frame, limit): if frame.empty: return {} chosen = {region: [] for region in ____(frame['region'].unique())} ordered = frame.sort_values(['revenue', 'name'], ascending=[____, ____]) best = ordered.groupby('region').____(limit) for region, group in best.groupby('region'): chosen[region] = list(group['____']) return chosen
import pandas as pd
def top_sellers(frame, limit):
if frame.empty:
return {}
# Every region gets a key, taken from the data rather than from the
# slice — so a limit of 0 leaves empty lists instead of missing regions.
chosen = {region: [] for region in sorted(frame["region"].unique())}
# One sort, two keys: revenue descending, name as the tiebreak.
ordered = frame.sort_values(
["revenue", "name"], ascending=[False, True]
)
# head is positional, which is exactly what is wanted now that the rows
# are in the right order.
best = ordered.groupby("region").head(limit)
for region, group in best.groupby("region"):
chosen[region] = list(group["name"])
return chosen
Building the result by iterating over what survived the filter means anything that filtered down to nothing vanishes — a limit of 0, a region whose rows were all excluded, a group that turned out to be empty. Seeding the dict from frame['region'].unique() first separates 'which regions exist' from 'who is in each one', and those are genuinely two questions. The pattern applies well beyond pandas: a report’s rows should come from the population, not from the results.
There is no 'top n per group' function because there does not need to be one: ordering the rows turns the positional slice into the ranked one. That composition is worth recognising because it keeps working — sort by date and head(1) is 'the earliest per group', tail(1) is 'the latest', head(n) after a shuffle is 'a random n per group'. One sort, one slice, many questions.
They return the same rows and the head version is several times faster, because it is computed as a single vectorised position-within-group rather than by calling a Python function once per group. The apply form also brings back a MultiIndex you then have to reset. When a groupby method exists, it is almost always both shorter and quicker than the apply that reimplements it.
The sort orders the entire frame to answer a question about a handful of rows per group, which is wasteful when the frame is very large and the limit is very small: groupby('region')['revenue'].nlargest(limit) finds them without a global sort, at the cost of giving you a MultiIndexed Series of values rather than rows — so you pay in a second lookup and in readability, and you lose the name tiebreak unless you sort within it anyway. Measure before you take that trade. And if the caller wants a frame rather than a dict of lists, stop at `best`: it is already one row per winner, with every original column, and the dict is a conversion that throws the revenue away.
- Return each seller’s revenue alongside their name, and check that the winners are in descending order.
- Add a rank column to the slice so the output can say 'second in the north' without counting list positions.
- Take the bottom `limit` instead, and change as little as possible to do it.