sort_values in pandas — sorting that scales
Contents:
Why sort_values shows up in every notebook
If you open ten random analyst notebooks at Stripe, DoorDash or Notion, at least eight of them call sort_values() in the first thirty cells. Top-N customers by revenue, latest events per session, "show me the worst churn cohorts" — every one of those reduces to the same pattern: take a DataFrame, pick an ordering key, and return the rows that matter most. The mechanics are mundane, but the defaults are sharp and the performance ceiling is lower than people assume once a frame crosses a few million rows.
This post walks through sort_values() the way a working analyst actually uses it: not as a one-line API call, but as a tool with quirks around NaN placement, stability, memory, and top-k shortcuts that are tested in every interview at FAANG-tier shops. We will keep the code dense and the prose short. If you have ever had a recruiter ask "what's the difference between sort_values().head(10) and nlargest(10, ...)?", this is the article you wish you had read the night before.
Load-bearing default: sort_values() returns a new DataFrame, never mutates in place, and puts NaN at the end. Memorize those three things and 80% of the surprises go away.
The base call and its defaults
import pandas as pd
df = pd.DataFrame({
'user': ['ivan', 'anna', 'maria', 'peter'],
'revenue': [5000, 12000, 3200, 8500],
'orders': [3, 7, 2, 5],
})
# Sort by revenue, largest first
df.sort_values('revenue', ascending=False)
# user revenue orders
# 1 anna 12000 7
# 3 peter 8500 5
# 0 ivan 5000 3
# 2 maria 3200 2A few defaults are worth flagging out loud. ascending=True is the default, so any "top N" query needs an explicit ascending=False. The original index is preserved — row labels follow the data, which is convenient until you plot or export and wonder why the row numbers look scrambled. inplace=False is the default too; a call without reassignment does nothing visible to your frame. This trips up almost every junior analyst exactly once.
# Wrong — sorted result is thrown away
df.sort_values('revenue', ascending=False)
# Right — reassign
df = df.sort_values('revenue', ascending=False)
# Also right but discouraged in modern pandas
df.sort_values('revenue', ascending=False, inplace=True)Modern style guides at companies like Databricks and Anthropic prefer reassignment over inplace=True because it composes cleanly with method chains and works the same way in lazy frameworks like Polars and PySpark.
Sorting by multiple columns
The second most common sort in any analytics codebase is grouped-then-ordered: rows clustered by one column, then ordered by a numeric column inside each group. sort_values takes a list of column names and a parallel list of directions.
df = pd.DataFrame({
'department': ['Sales', 'Sales', 'Dev', 'Dev', 'Sales'],
'name': ['Ivan', 'Anna', 'Peter', 'Maria', 'Oleg'],
'salary': [90000, 120000, 150000, 130000, 90000],
})
df.sort_values(['department', 'salary'], ascending=[True, False])
# department name salary
# 2 Dev Peter 150000
# 3 Dev Maria 130000
# 1 Sales Anna 120000
# 0 Sales Ivan 90000
# 4 Sales Oleg 90000The list lengths must match. Pass ascending=False as a scalar to flip every column, or pass a list to flip them independently. The sort is lexicographic across the column list — pandas sorts by the first column, then breaks ties with the second, then the third. That ordering matters: ['department', 'salary'] produces a department-grouped view, while ['salary', 'department'] produces a global salary ranking that just happens to print the department alongside.
NaN handling, custom keys, and sort_index
Real-world frames have missing values, and pandas has an opinion about where they go.
df = pd.DataFrame({
'name': ['Ivan', 'Anna', 'Peter', 'Maria'],
'score': [85, None, 92, None],
})
df.sort_values('score') # NaN at the bottom
df.sort_values('score', na_position='first') # NaN at the topna_position='last' is the default. Flipping to 'first' is useful during data quality reviews: anomalies float to the top of the screen instead of hiding at the bottom of a 10,000-row notebook output.
The key parameter lets you transform a column just for the comparison without mutating the underlying data. This is how you handle case-insensitive sorts, length-based sorts, or magnitude-based sorts cleanly.
# Case-insensitive
df.sort_values('name', key=lambda s: s.str.lower())
# By string length
df.sort_values('name', key=lambda s: s.str.len())
# By absolute value of a delta column
df.sort_values('change', key=abs)sort_index() is the sibling method that orders by the index rather than column values. It comes up after groupby operations that leave a non-trivial index, and during time series work where the timestamp lives in the index.
df = pd.DataFrame({'value': [30, 10, 20]}, index=['c', 'a', 'b'])
df.sort_values('value') # 10, 20, 30
df.sort_index() # rows ordered: a, b, cnlargest and nsmallest for fast top-k
When you only need the top or bottom k rows, sort_values().head(k) is overkill — it sorts the entire frame just to throw away most of it. nlargest(k, col) and nsmallest(k, col) use a partial-sort algorithm whose cost is roughly O(n + k log n) rather than the O(n log n) of a full sort.
# Top 3 by revenue
df.nlargest(3, 'revenue')
# Bottom 3
df.nsmallest(3, 'revenue')
# Tie-breaking by a second column
df.nlargest(5, ['revenue', 'orders'])On a frame of a few hundred thousand rows the difference is invisible. On the hundred-million-row event tables that live in modern Snowflake or Databricks notebooks pulled into pandas via Arrow, the gap is large and easy to measure with %timeit. Use the specialized methods whenever the answer is a leaderboard.
Sanity check: if the cell is df.sort_values(col, ascending=False).head(k), rewrite it as df.nlargest(k, col). The result is identical and the runtime is strictly better.
Real analyst patterns
The same three patterns show up across product analytics, growth, and finance work. Memorizing them shortens every interview by a few minutes.
Top-N users by revenue. Aggregate first, then sort, then take the head. This is the answer to "give me your top 10 spenders this quarter."
top_users = (
df.groupby('user_id')['amount']
.sum()
.sort_values(ascending=False)
.head(10)
)Chronological ordering for funnel analysis. Cast the timestamp, sort, and the frame is ready for groupby('user_id').first() / last() or a shift() to compute time-between-events.
df['event_time'] = pd.to_datetime(df['event_time'])
df = df.sort_values('event_time')Within-group ranking. Two equivalent patterns: a sort followed by cumcount() inside a groupby, or a direct rank() call. The second is closer to the SQL DENSE_RANK() semantics that an interviewer is likely testing.
# Pattern A: sort + cumcount
df['rank'] = (
df.sort_values(['department', 'salary'], ascending=[True, False])
.groupby('department')
.cumcount() + 1
)
# Pattern B: rank() — closer to SQL DENSE_RANK
df['salary_rank'] = df.groupby('department')['salary'].rank(
ascending=False, method='dense'
)The rank() form is what the SQL window-functions question is really asking — keep it in your head as the bridge between the two languages.
sort_values vs SQL ORDER BY
The mental model maps almost one-to-one. The table below is the cheat sheet most analysts wish they had pinned to their second monitor during interviews.
| pandas | SQL |
|---|---|
df.sort_values('col') |
ORDER BY col ASC |
df.sort_values('col', ascending=False) |
ORDER BY col DESC |
df.sort_values(['a', 'b'], ascending=[True, False]) |
ORDER BY a ASC, b DESC |
df.nlargest(10, 'col') |
ORDER BY col DESC LIMIT 10 |
df.sort_values('col', na_position='first') |
ORDER BY col NULLS FIRST |
df.sort_index() |
ORDER BY <index column> |
One subtle difference: pandas defaults to NaN last regardless of direction, while SQL dialects vary. Postgres puts NULL last for ASC and first for DESC by default, BigQuery puts NULL first for ASC, and Snowflake follows Postgres. If you are translating a query and the row order looks wrong by one or two rows, the culprit is almost always NULL placement, not the sort key.
Common pitfalls
When teams sort in pandas at scale, the most frequent mistake is forgetting that sort_values() is not in-place. An analyst writes df.sort_values('revenue', ascending=False) at the top of a cell, sees the printed output, then scrolls down assuming df itself is now ordered — only to be confused when a downstream head(10) returns the unsorted top. The fix is mechanical: reassign with df = df.sort_values(...), or wire the sort into a method chain so the result is consumed immediately. The same trap exists with drop_duplicates, fillna, and rename — pandas is mutation-conservative by design.
The second pitfall is lexicographic sorts on string-typed numbers. A CSV import that read a numeric column as strings will sort '10' before '2' and '9', because the comparison happens character by character. The diagnostic is a dtypes check; the fix is df['col'] = df['col'].astype(int) (or pd.to_numeric(df['col'], errors='coerce') if some rows might be junk). Date columns have the same problem in DD.MM.YYYY format — ISO YYYY-MM-DD strings sort correctly because the most significant character is on the left, but localized formats do not.
A third trap is using sort_values().head(k) when nlargest(k, ...) exists. The two return the same result, but the runtime gap on large frames is the difference between a 50-millisecond cell and a 5-second cell. It also matters for memory: a full sort allocates a copy of the entire frame, while nlargest only materializes the top k. On a notebook running inside a constrained Vercel function or a small Databricks instance, that allocation can push you over the memory budget and crash the kernel with no obvious cause.
A fourth, sneakier pitfall is relying on stability without thinking about it. Pandas defaults to mergesort, which is stable — rows with equal sort keys keep their original order. If you pass kind='quicksort' for speed, you lose that guarantee, and any downstream logic that assumed first-seen ordering (deduplication via drop_duplicates(keep='first'), for example) will start returning different answers across runs. Stick with the default unless you have benchmarked the alternative.
Finally, chained inplace operations. Writing df.sort_values('col').reset_index(drop=True) works because the first call returns a frame, but writing df.sort_values('col', inplace=True).reset_index(drop=True) raises AttributeError: 'NoneType' object has no attribute 'reset_index' — inplace returns None. Choose one style per pipeline and stay consistent.
Related reading
- pandas cheat sheet for analysts
- pandas performance optimization
- pandas merge guide
- ORDER BY and LIMIT in SQL
- SQL window functions interview questions
If you want to drill pandas and SQL ordering questions like these every day, NAILDD is launching with 500+ analyst problems built around exactly this pattern.
FAQ
How do I sort by a date column reliably?
Cast the column to a real datetime with pd.to_datetime(df['col']) first, then call sort_values('col'). String dates in ISO format (YYYY-MM-DD) happen to sort correctly because the most significant component sits on the left, but anything localized (MM/DD/YYYY, DD.MM.YYYY) will sort wrong. The same casting step also unlocks .dt accessors for downstream filtering and grouping, so it pays for itself even if you only needed the sort.
What's the difference between sort_values and sort_index?
sort_values(col) orders rows by the values inside a column. sort_index() orders rows by the labels of the index itself. They look similar in single-column DataFrames where you never set a custom index, but they diverge the moment you call set_index('user_id') or finish a groupby that leaves a non-trivial index. Use sort_index when the meaningful key is in the index (timestamps after a set_index('event_time'), for example), and sort_values when it lives in a column.
Is the sort stable, and does it matter?
Pandas defaults to mergesort, which is stable: rows with equal sort keys preserve their original relative order. That guarantee matters whenever a downstream step depends on first-seen ordering — most commonly drop_duplicates(keep='first'), groupby().first(), or a shift() operation after a sort. If you switch to kind='quicksort' or kind='heapsort' for speed, you give up stability, and those downstream steps will start returning different answers on different runs of the same code. Most of the time the right answer is "leave the default alone."
How do I keep the index continuous after sorting?
Chain reset_index(drop=True) after the sort: df.sort_values('revenue', ascending=False).reset_index(drop=True). Without drop=True the old index is preserved as a new column called index, which is rarely what you want. The continuous index matters most when you are about to write to CSV, Parquet, or a database — sparse index labels survive the round trip and confuse the next person to read the file.
When should I prefer nlargest over sort_values().head()?
Almost always, with one exception. nlargest(k, col) uses a partial-sort algorithm that is roughly O(n + k log n) versus the full O(n log n) of sort_values, and the gap grows with frame size. The one case where sort_values().head() is justified is when you also need the sorted remainder of the frame for downstream work — nlargest only gives you the top k and discards everything else. If you are returning a leaderboard and nothing else, use nlargest.
Can I sort on a derived value without adding a column?
Yes — the key parameter takes a callable that pandas applies to the sort column before comparing. df.sort_values('name', key=lambda s: s.str.lower()) does a case-insensitive sort without touching the underlying name column, and df.sort_values('change', key=abs) ranks by magnitude without losing the sign in the output. The transformation is comparison-only; the printed frame still shows the original values.