Sorting and Ranking

sort_values, nlargest and rank - and where the missing values end up.

Overview

sort_values

df.sort_values("sales") sorts by one column. A list sorts by several, in order, and ascending takes a matching list so each key can have its own direction:

df.sort_values(["city", "sales"], ascending=[True, False])

That is "by city A to Z, and within each city by sales high to low" — the shape of most reporting orders.

The result is a new frame carrying the original index labels. If downstream code thinks positionally, reset_index(drop=True) afterwards.

kind selects the algorithm. The default is not stable, so equal rows can come out in any order. Pass kind="stable" when ties must keep their original relative order — which matters if you are sorting by a second key after already sorting by a first, rather than passing both keys at once.

Worth knowing

sort_values takes one or several columns and an ascending list per key; it returns a new frame carrying the original labels.
Missing values sort last in both directions, so reversing an ascending sort is not the same as sorting descending.
sort_index orders by label, sort_values by data; sort_index(axis=1) orders the columns.
nlargest(n, col) is much faster than sorting everything and taking the head.
rank defaults to averaging ties; min gives competition ranking and dense leaves no gaps.
Sorting is n log n and copies — use a reduction for an extreme, nlargest for a top n, and sort only when order is the output.

Sorting and Ranking

sort_values, nlargest and rank, and where the missing values end up.

sort_values takes one column or several

And returns a new frame, keeping the original index labels.

example_01.pypandas
Output

Missing values go last, whatever the direction

Which means descending order does not simply reverse ascending order.

example_02.pypandas
Output

sort_index versus sort_values

One orders by the labels, the other by the data.

example_03.pypandas
Output

nlargest beats sorting the whole frame

When you only want the top few, do not order the rest.

example_04.pypandas
Output

rank, and what to do with ties

The default averages tied ranks, which is often not what a leaderboard wants.

example_05.pypandas
Output

Sorting is not free

It is n log n and it copies, so do it once and late.

example_06.pypandas
Output

Missing values sort last

NaN goes to the end in both ascending and descending order. It is not treated as very large or very small; it is simply put aside.

The consequence catches people: reversing an ascending sort is not the same as sorting descending, because the NaNs move relative to everything else.

na_position="first" puts them at the front instead, which is useful when the missing rows are the ones you want to look at.

sort_index

sort_index() orders by the index labels rather than the data. It is what you want after operations that leave the index shuffled, and it is required for label-range slicing on a non-unique index.

sort_index(axis=1) orders the columns alphabetically, which is a quick way to make two frames comparable.

Group-by results already come back sorted by key, so an extra sort_index there is usually redundant.

nlargest and nsmallest

Sorting 200,000 rows to look at five is wasted work.

df.nlargest(5, "v") finds the top five without ordering the rest, and the last-but-one editor measures the difference.

Both take a list of columns for tie-breaking, and a keep argument controlling what happens when the boundary value is tied: "first", "last" or "all". keep="all" can return more than n rows, which is occasionally what you want and worth knowing before it surprises you.

For a single extreme value, neither is needed: df["v"].max() is a reduction and far cheaper than any sort. idxmax() gives the label of the row that holds it, which is how you get "the row with the highest value" in one step.

rank

rank converts values into positions, and the interesting part is what it does with ties.

method="average" (the default) gives tied values the mean of the ranks they span — two values tied for 2nd and 3rd both get 2.5. That is the statistically conventional choice and it produces fractional ranks, which surprises people expecting integers.

method="min" gives both 2 and skips 3. This is competition ranking, the "joint second" of a leaderboard.

method="max" gives both 3.

method="dense" gives both 2 and makes the next value 3 — no gaps, which is what you want for grouping into levels.

method="first" breaks ties by order of appearance, giving strictly distinct integer ranks.

ascending=False ranks from the top. pct=True returns percentiles rather than positions, which is the quick route to "what fraction of rows are below this one".

rank also takes a na_option, and by default missing values get NaN ranks rather than being placed anywhere.

The cost

Sorting is O(n log n) and allocates a new frame. On a large table it is one of the more expensive things you can do.

Three cheaper alternatives cover most reasons people sort:

An extreme value: max, min, idxmax, idxmin — a single linear pass.

A top n: nlargest / nsmallest.

Deduplication order: sorting is genuinely required here, and it is the one case where sorting before drop_duplicates is not optional.

Sort when the order is the actual output — a report, a chart, a file someone will read. Otherwise there is usually a reduction that answers the same question for a fraction of the cost.

Sorting by something that is not a column

key= applies a function to the sort keys before comparing, without changing the data:

df.sort_values("city", key=lambda s: s.str.lower())

That sorts case-insensitively while leaving the original values intact. It is much cleaner than adding a helper column, sorting, and dropping it.

For a custom order — small, medium, large — the right tool is an ordered categorical:

df["size"] = pd.Categorical(df["size"], ["small", "medium", "large"], ordered=True)
df.sort_values("size")

The order then belongs to the column and applies to every later sort, group-by and comparison, rather than being repeated at each call site.

Sorting by string length, by the last character, or by any derived value is key= with the appropriate .str operation.

Sorting a group-by result

Group-by output arrives sorted by key. Usually you want it sorted by the value:

df.groupby("city")["sales"].sum().sort_values(ascending=False)

sort=False on the group-by itself skips the key sort, which is faster on many groups and gives order of first appearance.

For "the top n within each group", the pattern is a group-wise rank and a filter:

df[df.groupby("city")["sales"].rank(ascending=False) <= 3]

groupby(...).head(3) also works and takes the first three rows of each group in current order, so it needs a sort first to mean "the top three".

Stability, in practice

The default sort is not stable, so equal elements can come out in any order.

That matters in two situations.

A multi-pass sort. Sorting by one column, then another, only works if the second sort preserves the first ordering. Pass kind="stable", or better, sort by both keys in one call with a list.

Reproducible output. Two runs on the same data can order ties differently, which makes diffs noisy and tests flaky. kind="stable" fixes it.

The cost is small enough that using kind="stable" by default when the output is compared or written to a file is a reasonable habit.

Sorting large frames

Sorting is O(n log n) and copies the frame. On a large table it is often the most expensive single operation in a script.

The alternatives, in order of preference:

A reductionmax, min, idxmax, idxmin for an extreme.

nlargest / nsmallest for a top n.

sort_index if the data is already nearly sorted by that key; it is cheaper than a full value sort.

Sorting once and reusing the result, rather than sorting inside a loop or a function called repeatedly.

And when the sort *is* the output — a report, a leaderboard, a file someone reads — it is not overhead, it is the deliverable.

Ranking, and what to do with ties

The five methods differ only in tie handling, and the right choice depends on what the rank is for.

For a leaderboard, min gives the familiar "joint second, then fourth".

For levels or bands, dense avoids gaps.

For a statistic — a rank correlation, a percentile — average is the conventional choice and is the default for that reason.

For a deterministic ordering where ties must be broken, first uses order of appearance, which means the result depends on how the frame is sorted.

pct=True gives percentile ranks in [0, 1], which is the quick route to "what fraction of rows are at or below this one" and is comparable across datasets of different sizes.

Sorting the columns rather than the rows

df.sort_index(axis=1) orders columns alphabetically, which makes two frames easier to compare and diffs easier to read.

For a deliberate order, selection is clearer: df[["id", "date", "value"]].

For a partial order — pin some columns to the front, keep the rest — build the list:

first = ["id", "date"]
df = df[first + [c for c in df.columns if c not in first]]

That survives a schema change, where a hard-coded full list does not.

Sorting for output

When the sort is the deliverable, a few details matter that do not otherwise.

na_position="first" when missing rows are what the reader should notice.

key= for case-insensitive or natural ordering.

An ordered categorical for a domain order — small/medium/large — so every later operation uses it too.

reset_index(drop=True) if row numbers will be displayed, so they read 0, 1, 2 rather than the original labels.

kind="stable" so that re-running produces byte-identical output, which matters if the result is committed or diffed.

Ties, restated

The choice of tie-handling is a decision about meaning, not a technicality:

average — the statistical convention; produces fractional ranks.

min — competition ranking; joint second, then fourth.

dense — no gaps; for bands and levels.

max — the pessimistic reading.

first — arbitrary but deterministic, and dependent on the current row order.

If ranks are shown to people, min or dense usually matches their expectations. If ranks feed a statistic, average is the right default.

A summary

sort_values for data, sort_index for labels.

A list of keys with a matching list of directions handles multi-key sorts in one call.

Missing values go last in both directions, so reversing is not the same as descending.

nlargest/nsmallest for a top n; idxmax/idxmin for a single extreme.

key= for derived orderings; ordered categoricals for domain orderings.

kind="stable" when ties must keep their order or output must be reproducible.

And sorting is expensive — do it once, late, and only when the order is part of the answer.

A closing note

Sorting is expensive and frequently unnecessary, which makes it worth asking what the order is for.

If the answer is "to find the largest", a reduction does it in one pass. If it is "to find the top ten", nlargest does it without ordering the rest. If it is "so deduplication keeps the right row", the sort is essential and skipping it makes the result arbitrary. If it is "because the output is a report", the sort is the deliverable and its cost is the point.

Ranking is the same operation asked differently, and its only real subtlety is ties. The default averages them, which produces fractional ranks and surprises people expecting integers; leaderboards usually want min, and bands usually want dense.

And missing values sort last in both directions, which means reversing an ascending sort is not the same as sorting descending — a small asymmetry that produces a wrong answer at the ends.

One more thing

sort_values accepts ignore_index=True, which renumbers the result rather than carrying the original labels along. That saves a separate reset_index(drop=True) when the sorted output is going to be displayed or written with row numbers.

And nlargest and nsmallest exist on groupby objects too, so "the top three per group" is df.groupby("city")["sales"].nlargest(3) — which returns a MultiIndexed Series with the group and the original label, and usually wants reset_index before going anywhere else.

A note on reproducibility

Output that will be committed to a repository, diffed, or compared between runs should be deterministic, and sorting is where non-determinism creeps in.

The default sort is not stable, so tied rows can appear in different orders on different runs or different pandas versions. A file written from that output produces spurious diffs, and a test comparing it fails intermittently.

kind="stable" fixes it, at a cost small enough to ignore.

The same applies to group-by, which sorts keys by default but says nothing about the order of rows within a group, and to drop_duplicates, whose survivor depends entirely on the current order.

For anything whose output is compared, the rule is: sort explicitly, sort stably, and make the sort keys sufficient to determine the order uniquely.

Check yourself

0 of 4

Answer without scrolling back up.

  1. Where do NaN values go when sorting descending?

  2. You need the 5 highest rows from 200,000. What is the right call?

  3. Two values tie. What does the default `rank()` give them?

  4. When is sorting genuinely required rather than avoidable?

Cheat sheet

Sorting and Ranking

df.sort_values("sales") sorts by one column. A list sorts by several, in order, and ascending takes a matching list so each key can have its own direction:

PANDAS · vizlearn.in/pandas/sorting_and_ranking.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.