[PR-12515] Adding SeriesGroupBy.nlargest and nsmallest
dask.dataframe.SeriesGroupBy lacks nlargest and nsmallest, blocking group-wise top-N queries that the pandas API supports.

dask/dask Issue #10632: https://github.com/dask/dask/issues/10632
Description
dask.dataframe.SeriesGroupBy did not implement nlargest or nsmallest, both of which
exist on the pandas SeriesGroupBy. Any group-wise top-N query — "the three highest values
within each group" — had no direct equivalent in dask, forcing users into a full sort or a
groupby().apply() with a user-defined function, neither of which parallelizes well.
Background
Dask's dataframe groupby reductions are built on the ApplyConcatApply (ACA) pattern from the
expression system: a chunk function runs on each partition, a combine function merges
intermediates in batches down a reduction tree, and an aggregate function produces the final
result. Most single-method aggregations (sum, min, idxmax, value_counts) are declared
declaratively as SingleAggregation subclasses that only name their chunk and aggregate
callables.
Problem:
SeriesGroupBy.nlargestand.nsmallestwere simply absent from the dask implementation.- The issue was raised in the context of the duckdb db-benchmark suite, whose query 8 is a group-wise top-N.
- Because
GroupBy.__getattr__falls back to column selection, calling.nlargestproduced a confusingAttributeErrorrather than a clear "not implemented".
Impact:
- Group-wise top-N queries had no scalable implementation in dask.
- Benchmarks comparing dask against DuckDB and polars could not run the query at all.
- Code written against the pandas API broke when the frame was swapped for a dask dataframe.
Objective
Add SeriesGroupBy.nlargest and SeriesGroupBy.nsmallest that:
- Match the pandas result exactly, including the
(group_key, original_index)MultiIndex - Reduce in a tree rather than materialising each group
- Support the
nandkeepparameters where they are decomposable across partitions - Fail loudly and explicitly where they are not
Root Cause Analysis
The feature was never implemented rather than broken, so the analysis is about whether the operation decomposes across partitions at all.
-
SeriesGroupBy.nlargestkeeps the group keys in the result index:>>> df.groupby("a")["b"].nlargest(3).index.nlevels 2 # (a, original_index) -
This is the property that makes it decomposable. Because the group keys survive into the intermediate, the per-partition results can be regrouped on those index levels and reduced again — the top 3 of each partition's top 3 is the global top 3.
-
Regrouping adds a duplicate set of levels that has to be stripped:
chunk output index: (a, orig) regroup on level 0 → nlargest index: (a, a, orig) droplevel([0]) index: (a, orig) ✅ matches pandas -
The same shape already exists in dask for
GroupBy.head/.tail, whose_head_aggregatehelper performs exactly thisdroplevel— so the feature had a working precedent to follow. -
keep="last"is the exception and does not decompose. pandas returns tied values in reverse order of appearance in that mode:>>> s = pd.Series([0.5, 0.5, 0.5, 0.9], index=[14, 18, 20, 25]) >>> s.nsmallest(3, keep="last") 20 0.5 18 0.5 14 0.5 >>> s.nsmallest(3, keep="first") 14 0.5 18 0.5 20 0.5Since the reduction concatenates intermediates in partition order, a reversed intermediate causes the second pass to break ties in favour of the wrong row. This surfaced as a real test failure — a group whose correct answer was index
20returned index14.
Proposed Solution
-
Add aggregation helpers in
dask/dataframe/groupby.py, alongside the existing_head_aggregate:def _nlargest_aggregate(series_gb, **kwargs): levels = kwargs.pop("index_levels") return series_gb.nlargest(**kwargs).droplevel(list(range(levels))) -
Declare the expressions in
dask/dataframe/dask_expr/_groupby.py:class NLargest(SingleAggregation): groupby_chunk = M.nlargest groupby_aggregate = staticmethod(_nlargest_aggregate) -
Expose the public methods on
SeriesGroupBy, sharing one implementation, with the number of index levels to drop derived from the grouping keys. -
Reject
keep="last"explicitly:raise NotImplementedError( "keep='last' is not supported for a dask SeriesGroupBy, only " "keep='first' and keep='all'." ) -
Register both methods in the
docs/source/dataframe-api.rstAPI listing.
Implementation Details
-
dask/dataframe/groupby.py modifications:
- Added
_nlargest_aggregateand_nsmallest_aggregate, placed next to the_head_aggregate/_tail_aggregatehelpers they mirror
- Added
-
dask/dataframe/dask_expr/_groupby.py modifications:
- Added
NLargestandNSmallestasSingleAggregationsubclasses, usingM.nlargestandM.nsmallestdirectly as the chunk step - Added
SeriesGroupBy.nlargestand.nsmallest, both delegating to a shared_n_largest_smallesthelper, decorated with@derived_from(pd.core.groupby.SeriesGroupBy) - Deliberately did not override
combine, unlikeHead, which forces a plain concatenation. Letting the default combine run means each level of the reduction tree actually shrinks the intermediate rather than accumulating it - Methods were added only to
SeriesGroupBy, matching pandas —DataFrameGroupByhas nonlargest
- Added
-
docs/source/dataframe-api.rst:
- Added
SeriesGroupBy.nlargestandSeriesGroupBy.nsmallestto the API listing
- Added
Testing & Validation
Test fixtures pin 21 of 60 rows to an identical value, so that ties — the entire subtlety of
the keep parameter — are actually exercised rather than assumed away.
| ID | Scenario | Expected Result | Status |
|---|---|---|---|
| 1 | nlargest / nsmallest across n ∈ 10 | Results match pandas, including when n exceeds the group size | ✅ |
| 2 | Single key ("foo") and composite key (["foo", "bar"]) | Correct number of index levels dropped in both cases | ✅ |
| 3 | keep="first" and keep="all" with ties present | Tie-breaking matches pandas exactly | ✅ |
| 4 | keep="last" | Raises NotImplementedError with "keep='last'" in message | ✅ |
| 5 | split_every=2 (forces a multi-stage reduction tree) | Identical result to the single-stage reduction | ✅ |
| 6 | Grouping by a Series rather than a column name | Results match pandas | ✅ |
| 7 | Full existing groupby suite | No regressions | ✅ |
Validation command:
pixi run test dask/dataframe/tests/test_groupby.py -k "nlargest or nsmallest"
Output:
56 passed, 2234 deselected in 9.12s
Full groupby suite, checking for regressions:
pixi run test dask/dataframe/tests/test_groupby.py
Output:
1867 passed, 137 skipped, 286 xfailed in 953.15s (0:15:53)
Known Limitations
keep="last"is unsupported, for the tie-ordering reason above. A shuffle-based implementation could support it, at the cost of the tree reduction.split_out > 1raisesKeyError: 'None of [None] are in the columns'. This is a pre-existing limitation of this family of expressions rather than something introduced here —groupby.head(split_out=2)fails identically on unmodifiedmain— and is left for separate work.