Chapter 07

Sorting rows — and deciding what happens to ties

Putting rows in order with sort_values: one column or several, each in its own direction, missing values and text that looks like numbers — plus nlargest for "top N", and why a tie at the cut-off has to be a decision, not an accident.

40 minPython 3.12
  1. 1Encounter
  2. 2Understand
  3. 3Worked
  4. 4Predict
  5. 5Apply
  6. 6Stretch

The problem we are solving

The shop's owner sends a short message: "Which were our biggest orders this month? Send me the top three."

The data is the familiar orders.csv — eight orders from the shop's three branches — north, south and east. With eight rows you could answer by eye. Look down the quantity column, find the 30, then the 20, then the 12. Done.

Now imagine the real file: four thousand orders. Reading down a column to find the three largest numbers is no longer a method, it is a hope. And the owner's next message is already on its way: "Can you send me the list grouped by branch, biggest first inside each branch? And the newest orders at the top, please."

Every one of those requests is the same operation: put the rows in a useful order, then read from the top. That is sorting, and in pandas it is one method, sort_values().

But the method is the easy part. The real questions sit around it, and they are where reports go wrong:

  • "Biggest" by what? Quantity, price, or the money the order brought in? Each gives a different top three.
  • When two orders tie, which one comes first — and does the answer change every time you run the code?
  • What happens to an order whose quantity is blank?
  • If a column of numbers was read as text, "9" sorts after "30", and nothing warns you.

This chapter teaches the method and, more importantly, those decisions.

By the end of this chapter you can

  • Sort a table by one or several columns with sort_values(), each in its own direction, and keep the result instead of losing it
  • Explain why index labels travel with their rows, and choose between keeping them, sort_index() and reset_index(drop=True)
  • Break ties on purpose, and decide where missing values go with na_position
  • Answer "top N" questions with head(), nlargest() and nsmallest() — including "top N of a subset" and a tie at the cut-off
  • Sort text regardless of case with key=, and recognise numbers stored as text from the way they sort

Prerequisites: Missing data — finding, dropping and filling blanks.


Make the file first

Every example in this chapter reads orders.csv, the same file used since chapter four. If you do not have it yet, create it in your project folder with exactly these lines:

text
order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0

One section later uses a second file, orders_gaps.csv. It is the same table with two quantities left blank — orders 1002 and 1007 — the kind of gap chapter six taught you to find:

text
order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0

Before you write the code

Sorting looks too simple to plan. It is not, because a sort never fails — it always produces an order. If you sorted by the wrong thing, the result looks just as tidy as the right one. So the thinking has to happen before the code.

1. Write the question as one sentence, with the column in it. "Biggest orders" is not a question pandas can answer. "The three orders with the largest quantity" is. So is "the three orders with the highest price". They give different answers — the bag at 850 is the most expensive line, but only two bags were sold. Decide which one the owner means. If you cannot tell, ask; it costs one message, and a wrong top three costs trust.

2. Look at the input before you sort it. Three things matter for a sort: the number of rows, the types of the columns you will sort by, and whether those columns have blanks.

python
import pandas as pd

orders = pd.read_csv("orders.csv")

print(orders.shape)
print(orders.dtypes)
print(orders["quantity"].isna().sum())
text
(8, 7)
order_id      int64
date            str
branch          str
product         str
category        str
quantity      int64
price       float64
dtype: object
0

Eight rows, seven columns. quantity is int64 and price is float64, so they will sort as numbers. branch and product are str — text, which sorts alphabetically. date is also str. That is not a problem here, and the reason is worth knowing: dates written as YYYY-MM-DD sort correctly as text, because the most significant part comes first. Dates written as 4/3/2024 would not. And quantity has no blanks, so we do not need to decide where missing values go — yet.

3. Sketch the output. For "top three by quantity" the answer should be three rows, largest first: 30 (eraser, order 1005), then 20 (pen, 1007), then 12 (pen, 1001). Writing that down before running anything gives you something to check the code against.

4. Decide the details a sort needs.

  • Key — which column, or columns, decide the order? That becomes by=.
  • Direction — smallest first or largest first, for each key? That becomes ascending=.
  • Ties — if two rows have the same value, which comes first? That becomes a second key, or kind="stable".
  • Blanks — should missing values go first or last? That becomes na_position=.
  • How many — all rows, or only the top N? That becomes .head(n), or nlargest().
  • Index — do the old row labels still mean something afterwards? Keep them, or drop them with reset_index(drop=True).

5. Pick the tool.

  • Every row, in the order of one or more columns: sort_values()
  • Just the top or bottom N by a numeric column: nlargest() / nsmallest()
  • The rows back in index order: sort_index()
  • The top N inside a subset ("in the north branch"): filter first, then sort, then head()

6. Know how you will check it. After a sort, the first rows should match your sketch, shape should be unchanged (a sort never adds or removes rows), and for a numeric key the column should read monotonically down the page.


sort_values() — one column

To keep the printouts narrow, the examples show five of the seven columns. cols is just a list of names, used with the double-bracket selection from chapter four.

python
cols = ["order_id", "branch", "product", "quantity", "price"]

by_qty = orders.sort_values("quantity")

print(by_qty[cols])
text
order_id branch   product  quantity  price
5      1006  south       bag         1  850.0
2      1003  north       bag         2  850.0
7      1008  north    bottle         4  120.0
1      1002  south  notebook         5   60.0
3      1004   east    bottle         7  120.0
0      1001  north       pen        12   15.0
6      1007   east       pen        20   15.0
4      1005  north    eraser        30    8.0

The default is ascending — smallest first. Read the quantity column down the page: 1, 2, 4, 5, 7, 12, 20, 30. That is the check from the plan: a numeric key should only ever go up.

Now look at the left edge. The index is no longer 0, 1, 2, …. It reads 5, 2, 7, 1, 3, 0, 6, 4.

Why the index travels with the row

Pandas did not renumber the rows, and that is deliberate. An index label is the row's name, not its position. Label 5 was order 1006 before the sort and it is still order 1006 after it. If sorting renumbered the rows, every label would suddenly point at a different order, and anything you had noted about "row 5" would quietly become wrong.

So after a sort there are two different ways to say "the first row", and they no longer agree:

python
print(by_qty.iloc[0]["order_id"])
print(by_qty.loc[0]["order_id"])
text
1006
1001

iloc[0] means position zero — the first row on the page, order 1006. loc[0] means the row labelled 0 — order 1001, which is now sixth on the page. Chapter four drew this distinction; sorting is the operation that makes it matter. When you want "the top row after sorting", use iloc or head(), both of which count positions.

Largest first: ascending=False

python
print(orders.sort_values("quantity", ascending=False)[cols])
text
order_id branch   product  quantity  price
4      1005  north    eraser        30    8.0
6      1007   east       pen        20   15.0
0      1001  north       pen        12   15.0
3      1004   east    bottle         7  120.0
1      1002  south  notebook         5   60.0
7      1008  north    bottle         4  120.0
2      1003  north       bag         2  850.0
5      1006  south       bag         1  850.0

The top three are 1005, 1007 and 1001 — exactly the sketch from the plan. To send only those three, add head(3):

python
top3 = orders.sort_values("quantity", ascending=False).head(3)

print(top3[cols])
text
order_id branch product  quantity  price
4      1005  north  eraser        30    8.0
6      1007   east     pen        20   15.0
0      1001  north     pen        12   15.0

Reading the chain left to right gives the plan back: take orders, put the largest quantity first, keep the first three. This "sort, then head" pattern answers most "top N" questions.

Text sorts alphabetically

A str column sorts in dictionary order:

python
print(orders.sort_values("product")[cols])
text
order_id branch   product  quantity  price
2      1003  north       bag         2  850.0
5      1006  south       bag         1  850.0
3      1004   east    bottle         7  120.0
7      1008  north    bottle         4  120.0
4      1005  north    eraser        30    8.0
1      1002  south  notebook         5   60.0
0      1001  north       pen        12   15.0
6      1007   east       pen        20   15.0

bag before bottle because a comes before o. Two bags, two bottles and two pens — these are ties, and we will come back to the question of which of a tied pair comes first.


A sort returns a new table

This is the mistake almost everyone makes once, and it raises no error:

python
orders.sort_values("quantity", ascending=False)

print(orders[cols].head(3))
text
order_id branch   product  quantity  price
0      1001  north       pen        12   15.0
1      1002  south  notebook         5   60.0
2      1003  north       bag         2  850.0

Nothing sorted. sort_values() does not change orders; it builds and returns a new DataFrame in the new order. The first line created that sorted table and, because nobody stored it, threw it away. orders is exactly what read_csv produced.

Why does pandas work this way? Because the original order is information too. In orders.csv it is the order the orders arrived in. If every sort rearranged your only copy, you could never get back to "the way the file was", and a sort written for one report would silently change the input of the next. Returning a new object means sorting is always safe to try.

sort_values() never changes the table you call it on. If you do not assign the result, the sort happened and was thrown away — and nothing warns you.

So you keep the result, either in a new name or back in the same name:

python
by_qty_desc = orders.sort_values("quantity", ascending=False)   # keep both

print(by_qty_desc["order_id"].head(3).tolist())
print(orders["order_id"].head(3).tolist())
text
[1005, 1007, 1001]
[1001, 1002, 1003]

The sorted copy has the big orders on top; the original is untouched. Writing orders = orders.sort_values(...) is also fine when you are sure you no longer need the old order.

You will see inplace=True in older code. It changes the table in place and returns None, which causes its own bug (see When it breaks). With Copy-on-Write in pandas 3, inplace=True saves no memory either. Assigning the result is clearer: you can see on the line where the sorted table goes.


Several columns: priority and direction

The owner's second request: "grouped by branch, biggest first inside each branch." That is two keys, and the order of the list is the order of priority:

python
by_branch = orders.sort_values(["branch", "quantity"], ascending=[True, False])

print(by_branch[cols])
text
order_id branch   product  quantity  price
6      1007   east       pen        20   15.0
3      1004   east    bottle         7  120.0
4      1005  north    eraser        30    8.0
0      1001  north       pen        12   15.0
7      1008  north    bottle         4  120.0
2      1003  north       bag         2  850.0
1      1002  south  notebook         5   60.0
5      1006  south       bag         1  850.0

Read it the way pandas built it. First, every row is placed by branch, A to Z: east, north, south. The second key, quantity, is only consulted when the first key ties — inside east, inside north, inside south. Inside north it runs 30, 12, 4, 2: largest first, because the second entry of ascending is False.

ascending takes one True/False per key, in the same order as by. A single False would apply to both keys and put south first.

Swapping the list changes the question entirely:

python
print(orders.sort_values(["quantity", "branch"], ascending=[False, True])[cols].head(4))
text
order_id branch product  quantity  price
4      1005  north  eraser        30    8.0
6      1007   east     pen        20   15.0
0      1001  north     pen        12   15.0
3      1004   east  bottle         7  120.0

Now quantity rules, and branch would only matter if two orders had the same quantity — which none do. The branches are scattered. So the question to ask before writing the list is: "what is the report organised by?" That column goes first.


Ties: make the order a decision, not an accident

Sort by price, most expensive first:

python
print(orders.sort_values("price", ascending=False)[cols])
text
order_id branch   product  quantity  price
2      1003  north       bag         2  850.0
5      1006  south       bag         1  850.0
7      1008  north    bottle         4  120.0
3      1004   east    bottle         7  120.0
1      1002  south  notebook         5   60.0
0      1001  north       pen        12   15.0
6      1007   east       pen        20   15.0
4      1005  north    eraser        30    8.0

Look at the two bottles at 120. In the file, order 1004 comes before 1008. In the sorted table, 1008 comes first. Nothing is broken: for a single key, pandas' default sorting algorithm (quicksort) does not promise to keep tied rows in their original order, and here it did not. The two bags at 850 stayed in file order, the two bottles swapped — there is no rule you could have predicted.

Usually that does not matter. It starts to matter the moment you cut the list. If the report is "the three most expensive orders", third place is a tie between 1004 and 1008, and which one makes the list was decided by the algorithm, not by you. Run the same code on a slightly different file and the answer can change.

There are two ways to take that decision back.

Option 1: a stable sort. kind="stable" promises that tied rows keep the order they had before the sort — here, file order:

python
stable = orders.sort_values("price", ascending=False, kind="stable")

print(stable["order_id"].tolist())
text
[1003, 1006, 1004, 1008, 1002, 1001, 1007, 1005]

Option 2 (usually better): an explicit tie-breaker. Say what should win a tie. For a sales report, "if the price is the same, the bigger order first" is a sensible rule:

python
tie_broken = orders.sort_values(["price", "quantity"], ascending=[False, False])

print(tie_broken[cols])
text
order_id branch   product  quantity  price
2      1003  north       bag         2  850.0
5      1006  south       bag         1  850.0
3      1004   east    bottle         7  120.0
7      1008  north    bottle         4  120.0
1      1002  south  notebook         5   60.0
6      1007   east       pen        20   15.0
0      1001  north       pen        12   15.0
4      1005  north    eraser        30    8.0

Look at the bottles again: 1004 is above 1008, because 7 beats 4. And the two pens at 15: before, 1001 came first; now 1007 wins, because 20 beats 12 — and it will win every time, on any machine, whatever order the file arrives in. A tie-breaker turns an accident into a rule you can explain to the owner. (When you sort by more than one column, pandas' sort is stable anyway, so a list of keys never shuffles real ties either.)

If you have no business rule, order_id makes a fine last key: it is unique, so after it there are no ties left at all.


Missing values: na_position

Read the version of the file with two blank quantities:

python
gaps = pd.read_csv("orders_gaps.csv")

print(gaps["quantity"].isna().sum())
print(gaps.sort_values("quantity", ascending=False)[cols])
text
2
   order_id branch   product  quantity  price
4      1005  north    eraser      30.0    8.0
0      1001  north       pen      12.0   15.0
3      1004   east    bottle       7.0  120.0
7      1008  north    bottle       4.0  120.0
2      1003  north       bag       2.0  850.0
5      1006  south       bag       1.0  850.0
1      1002  south  notebook       NaN   60.0
6      1007   east       pen       NaN   15.0

Two things to notice. First, quantity now prints as 30.0, 12.0 — the blanks turned the column into float64, as chapter six explained, because NaN is a float. Second, the NaN rows went to the bottom, even though we asked for largest first. That is the default, na_position="last", and it applies in both directions: a missing value is not "small" or "large", it is unknown, so pandas sets it aside rather than guessing.

Notice what that did to the top three. Order 1007 really sold 20 pens, but its quantity is blank in this file, so it fell out of the top three and 1004 took its place. A sort cannot fix missing data; it can only show you where the gaps went. That is why step 2 of the plan counts blanks before sorting.

When you are auditing the data, you often want the opposite — the problems on top, where you will see them:

python
print(gaps.sort_values("quantity", na_position="first")[cols].head(3))
text
order_id branch   product  quantity  price
1      1002  south  notebook       NaN   60.0
6      1007   east       pen       NaN   15.0
5      1006  south       bag       1.0  850.0

So na_position is not a formatting choice. "last" suits a report, where blanks should not crowd out real values. "first" suits a check, where blanks are exactly what you are looking for.


Back to the start: sort_index() and reset_index()

Because every row kept its label, the original order is never lost. Sorting by the index brings it back:

python
restored = by_qty.sort_index()

print(restored["order_id"].tolist())
text
[1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008]

sort_index() takes ascending=False too, which is how you would list a table "newest label first".

Sometimes the old labels have stopped meaning anything. If you sort to produce a ranked list and send it on, the labels 4, 6, 0 down the side are just confusing. reset_index(drop=True) throws them away and numbers the rows 0, 1, 2, … in their new order:

python
ranked = orders.sort_values("quantity", ascending=False).reset_index(drop=True)

print(ranked[cols].head(3))
print(ranked.loc[0, "order_id"])
text
order_id branch product  quantity  price
0      1005  north  eraser        30    8.0
1      1007   east     pen        20   15.0
2      1001  north     pen        12   15.0
1005

Now loc[0] and iloc[0] agree again: both are order 1005, the biggest. Without drop=True, pandas would keep the old labels as a new column called index — useful if you want to trace rows back, clutter if you do not.

Which to choose? Keep the labels while you are still working with the data and might need to match rows back to the original. Reset them when the order itself is the result — a ranking, a top-ten list.


The shortcut for "top N": nlargest() and nsmallest()

"Top three by quantity" is so common that pandas has a method that says exactly that:

python
print(orders.nlargest(3, "quantity")[cols])
text
order_id branch product  quantity  price
4      1005  north  eraser        30    8.0
6      1007   east     pen        20   15.0
0      1001  north     pen        12   15.0

The same rows as sort_values("quantity", ascending=False).head(3). Why prefer it? The line states the intent — "the three largest" — instead of the mechanism. And on a large table it is faster, because it does not need to put all the rows in order just to keep three.

nsmallest() is the mirror image: the cheapest three lines in the shop:

python
print(orders.nsmallest(3, "price")[cols])
text
order_id branch product  quantity  price
4      1005  north  eraser        30    8.0
0      1001  north     pen        12   15.0
6      1007   east     pen        20   15.0

Ties at the cut-off: keep=

Ask for the three most expensive lines and something awkward happens:

python
print(orders.nlargest(3, "price")[cols])
text
order_id branch product  quantity  price
2      1003  north     bag         2  850.0
5      1006  south     bag         1  850.0
3      1004   east  bottle         7  120.0

Third place is 120 — but two orders cost 120, 1004 and 1008. pandas returned one of them — with the default keep="first", the one that appears first in the table — and silently left the other out. If you are awarding a prize to "the top three", that is unfair to 1008. keep="all" keeps every row that ties at the cut-off, even if that means more than N rows:

python
print(orders.nlargest(3, "price", keep="all")[cols])
text
order_id branch product  quantity  price
2      1003  north     bag         2  850.0
5      1006  south     bag         1  850.0
3      1004   east  bottle         7  120.0
7      1008  north  bottle         4  120.0

Four rows for a "top three". That is not a bug: it is the honest answer when third place is shared. Whether you want it is, again, a planning decision.

nlargest and nsmallest work only on numeric columns. For text, use sort_values and head.


Top N of a subset: filter, then sort, then cut

"What were the two biggest orders in the north branch?" This combines chapter five with this one, and the order of the steps matters:

python
north = orders[orders["branch"] == "north"]

top_north = north.sort_values("quantity", ascending=False).head(2)

print(top_north[cols])
text
order_id branch product  quantity  price
4      1005  north  eraser        30    8.0
0      1001  north     pen        12   15.0

Filter first, because the question is about the north branch only. If you sorted the whole table and took head(2) first, you would get 1005 and 1007 — and 1007 belongs to the east branch. Filtering afterwards would then leave just one row, and you would report a "top two" with one entry. The rule: narrow down to the rows the question is about, then rank them, then cut.

The same chain works with nlargest: orders[orders["branch"] == "north"].nlargest(2, "quantity").


Sorting text without caring about case: key=

Text sorting compares characters by their code, and every capital letter comes before every lowercase one. Data typed by people mixes the two:

python
names = pd.DataFrame({"product": ["pen", "Notebook", "bag", "Bottle", "eraser"]})

print(names.sort_values("product"))
text
product
3    Bottle
1  Notebook
2       bag
4    eraser
0       pen

Bottle and Notebook jump ahead of bag simply because they start with a capital. No reader would call that alphabetical.

You could fix the data, but often you should not — the spelling may be how the customer wrote it. The key= argument lets you sort by a transformed version of the column while keeping the original values. It takes a function that receives the whole column and returns the version to sort by:

python
print(names.sort_values("product", key=lambda col: col.str.lower()))
text
product
2       bag
3    Bottle
4    eraser
1  Notebook
0       pen

The lowercase copy is used only for comparing and then discarded. The table still shows Bottle and Notebook exactly as they were typed. (lambda col: col.str.lower() is a one-line function: given the column col, return it in lowercase.)


When numbers are stored as text

Here is the trap that the planning step about dtypes exists to catch. Suppose quantity arrived as text — a stray "N/A" in the file, or a system that exports everything as strings. To see what that does, we make such a column on purpose with astype:

python
as_text = orders.astype({"quantity": "str"})

print(as_text["quantity"].dtype)
print(as_text.sort_values("quantity")[["order_id", "quantity"]])
text
str
   order_id quantity
5      1006        1
0      1001       12
2      1003        2
6      1007       20
4      1005       30
7      1008        4
1      1002        5
3      1004        7

1, 12, 2, 20, 30, 4, 5, 7. No error, and an order that looks almost right. Text is compared character by character: "12" starts with 1, which is less than 2, so "12" comes before "2" — just as "ab" comes before "b" in a dictionary. The sort did exactly what it was told. It was told the wrong thing.

Two clues give it away: quantity is left-aligned in the printout, the way pandas prints text, and dtype says str. The real fix is to convert the column to numbers (chapter six's pd.to_numeric). If you must leave the column as it is, key= can sort by the numeric value instead:

python
fixed = as_text.sort_values("quantity", key=pd.to_numeric)

print(fixed["quantity"].tolist())
text
['1', '2', '4', '5', '7', '12', '20', '30']

Correct order now — but the values are still strings, so you could not add them up. Prefer converting the column once, right after reading the file. Then every sort, filter and sum that follows is right without you having to remember.


A complete example

The owner's full request, answered in one program: (1) the top three orders by money brought in, (2) the newest orders first, (3) a list by branch with the biggest order first in each.

"Money brought in" is quantity times price. Adding columns is covered properly later in the course; for now one line is enough — assigning to a new column name creates it.

sorted_report.py:

python
import pandas as pd

orders = pd.read_csv("orders.csv")

# Check the input before ranking anything
print("Rows, columns:", orders.shape)
print("Blank quantities:", orders["quantity"].isna().sum())
print()

orders["revenue"] = orders["quantity"] * orders["price"]
cols = ["order_id", "date", "branch", "product", "revenue"]

# 1. Top three by revenue, ties broken by the larger quantity, renumbered as a ranking
top3 = (
    orders.sort_values(["revenue", "quantity"], ascending=[False, False])
    .head(3)
    .reset_index(drop=True)
)
print("Top three orders by revenue")
print(top3[cols])
print()

# 2. Newest first; within the same day, the bigger order first
newest = orders.sort_values(["date", "revenue"], ascending=[False, False])
print("Newest first")
print(newest[cols].head(4))
print()

# 3. By branch A-Z, biggest order first inside each branch
by_branch = orders.sort_values(["branch", "revenue"], ascending=[True, False])
print("By branch")
print(by_branch[cols])
print()

print("Still eight rows:", by_branch.shape[0] == orders.shape[0])
text
Rows, columns: (8, 7)
Blank quantities: 0

Top three orders by revenue
   order_id        date branch product  revenue
0      1003  2024-03-02  north     bag   1700.0
1      1006  2024-03-03  south     bag    850.0
2      1004  2024-03-02   east  bottle    840.0

Newest first
   order_id        date branch product  revenue
7      1008  2024-03-04  north  bottle    480.0
6      1007  2024-03-04   east     pen    300.0
5      1006  2024-03-03  south     bag    850.0
4      1005  2024-03-03  north  eraser    240.0

By branch
   order_id        date branch   product  revenue
3      1004  2024-03-02   east    bottle    840.0
6      1007  2024-03-04   east       pen    300.0
2      1003  2024-03-02  north       bag   1700.0
7      1008  2024-03-04  north    bottle    480.0
4      1005  2024-03-03  north    eraser    240.0
0      1001  2024-03-01  north       pen    180.0
5      1006  2024-03-03  south       bag    850.0
1      1002  2024-03-01  south  notebook    300.0

Still eight rows: True

Why it is written this way:

  • The check comes first. shape and the blank count are printed before any ranking. A top three computed from a half-read file, or with blanks pushed to the bottom, would look just as confident.
  • "Biggest" was turned into a column. Ranking by quantity would put the 30 erasers on top — 240 worth of business. Ranking by revenue puts the two bags worth 1,700 on top. The plan's first step — write the question with the column in it — is what decides which answer the owner gets.
  • Every sort has a tie-breaker. On 2024-03-04 there are two orders; revenue decides which is listed first, so the report is the same on every run.
  • Only the ranking is renumbered. top3 is a finished list, so its labels become 0, 1, 2. newest and by_branch keep their original labels, so any row can still be traced back to the file.
  • orders itself is never re-sorted. Each report is a new table built from the same untouched input, so the three answers cannot interfere with each other.
  • The last line is a cheap assertion. A sort must never change the number of rows. Printing True costs nothing and would catch, for example, an accidental head() left in the wrong place.

When it breaks

KeyError: 'Quantity'

python
orders.sort_values("Quantity")
text
KeyError: 'Quantity'

The name in by= must match a column exactly — case, spaces and all. The column is quantity, lowercase. Print list(orders.columns) to see the real names, quotes and any stray spaces included, as chapter three recommended.

AttributeError: 'NoneType' object has no attribute 'head'

python
top = orders.sort_values("quantity", inplace=True)
top.head()
text
AttributeError: 'NoneType' object has no attribute 'head'

With inplace=True, sort_values changes orders and returns None. So top is None, and None has no head(). Choose one style: either orders.sort_values("quantity", inplace=True) on its own line, or — better — top = orders.sort_values("quantity") without inplace.

ValueError: Length of ascending (1) != length of by (2)

python
orders.sort_values(["branch", "quantity"], ascending=[False])
text
ValueError: Length of ascending (1) != length of by (2)

A list for ascending needs one entry per key. Two keys, two booleans: ascending=[True, False]. (A single plain False, not in a list, is allowed — it applies to every key.)

ValueError: For argument "ascending" expected type bool, received type str.

python
orders.sort_values("branch", ascending="False")
text
ValueError: For argument "ascending" expected type bool, received type str.

"False" in quotes is a string, not the boolean False. Remove the quotes.

TypeError: Column 'branch' has dtype str, cannot use method 'nlargest' with this dtype

python
orders.nlargest(3, "branch")
text
TypeError: Column 'branch' has dtype str, cannot use method 'nlargest' with this dtype

nlargest and nsmallest only rank numbers. For "last three branches alphabetically" use orders.sort_values("branch", ascending=False).head(3). If you got this error on a column you expected to be numeric, the real problem is that it was read as text — check dtypes.

TypeError: '<' not supported between instances of 'str' and 'int'

python
mixed = pd.Series([12, "unknown", 5])
mixed.sort_values()
text
TypeError: '<' not supported between instances of 'str' and 'int'

This column holds real numbers and a piece of text, so its dtype is object, and Python cannot say whether "unknown" is bigger or smaller than 12. Clean the column first: pd.to_numeric(mixed, errors="coerce") turns "unknown" into NaN, and then the sort works and na_position decides where the blank goes.