Worst offenders, cheapest stores, coldest sensors — the n rows you actually wanted from sort_values().head().
Your manager asks for the three worst-performing stores — and most people still sort a thousand rows to keep three.
df.nsmallest(n, 'col') returns the n rows with the smallest values in the column you name, sorted ascending — the exact mirror of nlargest(). NaN rows are skipped by default, ties at the cutoff follow the keep rule, and passing a list of columns gives you a tie-break chain: sort by the first column, settle ties on the second. The Series version takes no column argument, so s.nsmallest(5) is a one-call 'five lowest readings'.
Bottom-k questions are everywhere once you look for them: which stores are bleeding revenue, which sensors read coldest, which API endpoints are fastest to answer. The default idiom sort_values().head() pays full price — a complete O(n log n) sort of every row to keep three. nsmallest() was added (pandas 0.17.0, September 2015) so you would not have to: it partitions with quickselect instead of sorting, which on my machine is roughly 24x faster on 10 million rows. Sorting a table to keep three rows is paying for a full ranking you never asked for.
import pandas as pd
sales = pd.DataFrame({
"store": ["Berlin", "Hamburg", "Munich", "Cologne", "Frankfurt",
"Stuttgart", "Düsseldorf", "Leipzig"],
"region": ["east", "north", "south", "west", "west", "south", "west", "east"],
"revenue": [48200, 51100, 39400, 47650, 21000, 41200, 45900, 22800],
})
sales.nsmallest(3, "revenue")store region revenue 4 Frankfurt west 21000 7 Leipzig east 22800 2 Munich south 39400
The daily-driver move: the three worst stores by revenue, ranked rows included, in one call.
sales['at_risk'] = sales.index.isin(sales.nsmallest(3, "revenue").index) sales
store region revenue at_risk 0 Berlin east 48200 False 1 Hamburg north 51100 False 2 Munich south 39400 True 3 Cologne west 47650 False 4 Frankfurt west 21000 True 5 Stuttgart south 41200 False 6 Düsseldorf west 45900 False 7 Leipzig east 22800 True
The real workflow: use the index nsmallest() returns to flag at-risk rows on the full report.
readings = pd.DataFrame({
"sensor": ["S1", "S2", "S3", "S4", "S5"],
"reading": [10, 22, 22, 31, 10],
"drift": [0.4, 1.1, 0.9, 2.5, 0.2],
})
readings.nsmallest(3, ["reading", "drift"])sensor reading drift 4 S5 10 0.2 0 S1 10 0.4 2 S3 22 0.9
Multi-column tie-break: both S5 and S1 read 10, so the lower drift settles it; S3 wins the third slot at 22.
dups = pd.DataFrame({"city": ["A", "B", "C", "D"], "temp_c": [5.2, 5.2, 9.0, 3.0]})
dups.nsmallest(2, "temp_c", keep="all")city temp_c 3 D 3.0 0 A 5.2 1 B 5.2
Ties handled SQL-style: keep='first' (the default) would keep only A and drop B — 'all' keeps every row tied at the cutoff, even more than n rows.
s = pd.Series({"Mon": 41, "Tue": 12, "Wed": 88, "Thu": 12, "Fri": 55})
s.nsmallest(2)Tue 12 Thu 12 dtype: int64
The Series version takes no column argument. keep='first' kept Tue and dropped the tied Thu; keep='all' would return both.
| Flag | Meaning |
|---|---|
df.nsmallest(n, 'col') | the core call: n smallest rows by one column, returned sorted ascending |
df.nsmallest(n, ['col1', 'col2']) | tie-break chain: settle ties on the second column when the first is equal |
df.nsmallest(n, 'col', keep='all') | keep every row tied at the cutoff, even if that means more than n rows back |
df.nsmallest(n, 'col', keep='last') | on ties keep the later row instead of the first |
s.nsmallest(n) | Series version: no column argument, nsmallest defaults to n=5 |
df.index.isin(df.nsmallest(3, 'col').index) | turn the selection into a boolean mask to flag rows on the full frame |
df.sort_values('col').groupby('region').head(2) | per-group bottom-k: nsmallest has no groupby method, so sort + groupby head is the pattern |
nsmallest() and nlargest() arrived together in 0.17.0 (GH 10393) — the DataFrame methods, that is. Series.nsmallest() predates them: it landed back in 0.14.0 (May 2014, GH 3960). The 0.17.0 changelog also deprecated Series.nsmallest's old take_last keyword in favor of keep, unifying the tie rule across the pair.
keep='first' | 'last' | 'all' was in the method from the start — the same options as drop_duplicates(). The rule mirrors SQL's ORDER BY + LIMIT semantics: without keep='all', a tie at the cutoff silently drops rows, which is exactly what reports want when they say 'bottom 3' — and exactly what surprises people in code review.
Inside pandas (core/methods/selectn.py), SelectNFrame checks dtypes first, then delegates to the Series implementation, which partitions the column's numpy values instead of sorting them — an O(n) average quickselect that guarantees the n smallest values land on one side of a pivot. Only the n winning rows get re-sorted; the rest of the frame is stitched back with take() using the winning positions. For a multi-column tie-break, SelectNFrame actually runs nsmallest() once per column, narrowing the candidate set at each step. I measured 10M rows: sort_values().head(10) took 2.5s, nsmallest(10) took 0.1s on this machine — that is where the ~24x figure comes from.