Nothing broken here — just the comparison rule that explains the ticket. Read the second line and make sure it does not surprise you.
print(sorted(['9', '10', '100'])) # -> ['10', '100', '9']
print('9' > '100') # -> True
# Strings compare character by character, so '9' beats '1' immediately
# and the rest of the string is never even looked at.
print(sorted(['9', '10', '100'], key=int)) # -> ['9', '10', '100']
- Character by character, left to right
Comparing two strings compares their first characters; only if those match does it look at the second, and so on. '9' > '1' is true, so '9' sorts after '100' and the length never enters into it. This is exactly the order a dictionary uses, and exactly not the order a leaderboard needs.
- Why it only broke this week
While every score had the same number of digits, dictionary order and numeric order agreed, so the bug was invisible. It appeared the moment someone crossed into three digits. Bugs that wait for the data to change shape are the ones tests written from early data never catch.
- key= converts for comparison only
sorted(rows, key=int) sorts by what each item would be as a number while still returning the original strings. The key function is called once per item and its result is used purely for ordering — nothing in the list is modified or replaced.
The first case passes and the second does not, and the only difference between them is how many digits the scores have. What is being compared, and what type is it?
row['score'] is a string, so the sort is comparing text: '9' comes after '100' because '9' > '1' at the very first character. The values need to be compared as numbers.
The key function decides what each row is compared by. Convert the score to an integer inside it — the rows themselves stay exactly as they are, and only the ordering changes.
ranked = sorted(rows, key=lambda row: ____(row["score"]), reverse=True)
def top_scores(rows, count):
if count < 0:
raise ValueError(f"count cannot be negative, got {count}")
ranked = sorted(rows, key=lambda row: int(row["score"]), reverse=True)
return [row["name"] for row in ranked[:count]]
The whole fix is int() inside the key. It does not rewrite the rows, it does not need a second pass, and it leaves the strings as strings for everyone else — the conversion exists purely to answer 'which of these is bigger'. Reach for key= before you reach for restructuring the data.
The other honest fix is to parse the CSV into real integers the moment it arrives, so nothing downstream ever sees a string. That is usually better: one place to get it right instead of every place that touches a score. Fixing it in the sort is correct here because this function is all we were given. In your own systems, ask where the type should have been settled, and settle it there.
rows.sort() reorders the caller’s list in place and returns None; sorted() leaves theirs alone and hands back a new one. The starter already used sorted, and one of the cases exists to make sure your fix does not quietly change that while you are in there.
Do not use int() as the key if the scores might not be clean digits — one row containing '', 'N/A' or '1,200' turns a wrong ranking into a crashed page. Data that you do not control needs validating on the way in, where you can report which row was bad, rather than deep inside a sort where all you get is ValueError and no context. And if this is a real leaderboard over real volume, the database should be doing ORDER BY on a numeric column and returning the top N — not your process, and not on every request.
- Make it survive a malformed score by reporting which row is bad, and decide whether that means raising, skipping, or ranking it last.
- Return the scores alongside the names as real integers, and notice how much of the caller’s code that simplifies.
- Add a tie-break by name for equal scores, then argue with yourself about whether breaking the stable-order guarantee is an improvement or a surprise.