Skip to main content

Python Custom Sorting with Key Practice Problems & Exercises

Practice: Custom Sorting with Key

11 problems4 Easy4 Medium3 Hard40-55 min
← Back to lesson

#1Sort Strings by LengthEasy
key parameterlambdasorting basics

Given a list of strings, return a new list sorted by string length in ascending order. Words with the same length should keep their original relative order.

Example:

words = ["Python", "Go", "JavaScript", "Rust", "Java"]
print(sort_by_length(words))
# ['Go', 'Rust', 'Java', 'Python', 'JavaScript']
Solution
def sort_by_length(words):
return sorted(words, key=lambda w: len(w))

Explanation: key=lambda w: len(w) extracts the length of each string. Python calls this function once per element, caches the results, and sorts by those cached values. Since Timsort is stable, words with the same length ("Rust" and "Java" both have length 4) keep their original order.

You could also write key=len directly, since len is already a callable that takes one argument.

def sort_by_length(words):
  """Sort words by length (ascending).
  For equal-length words, preserve original order.

  Args:
      words: list of strings

  Returns:
      New sorted list
  """
  # TODO: use sorted() with a key function
  pass
Expected Output
['Go', 'Rust', 'Java', 'Python', 'JavaScript']
Hints

Hint 1: Use sorted() with key=lambda to extract the length of each string.

Hint 2: len(s) returns the length of a string. Python's sort is stable, so equal-length words keep their original order.

#2Sort Dicts by a Single FieldEasy
itemgetterdict sortingoperator module

Given a list of employee dictionaries, sort them by salary in descending order using operator.itemgetter.

Example:

employees = [
{"name": "Alice", "salary": 95000},
{"name": "Bob", "salary": 80000},
{"name": "Carol", "salary": 110000},
{"name": "Dave", "salary": 72000},
]
print(sort_employees_by_salary(employees))
Solution
from operator import itemgetter

def sort_employees_by_salary(employees):
return sorted(employees, key=itemgetter("salary"), reverse=True)

Explanation: itemgetter("salary") is a C-level callable equivalent to lambda e: e["salary"] but roughly 2x faster. The reverse=True parameter sorts in descending order. For a list of 1 million dicts, this C-level extraction saves measurable time.

from operator import itemgetter

def sort_employees_by_salary(employees):
  """Sort employee dicts by salary in descending order.
  Use operator.itemgetter for performance.

  Args:
      employees: list of dicts with keys 'name' and 'salary'

  Returns:
      New sorted list (highest salary first)
  """
  # TODO: use sorted() with itemgetter
  pass
Expected Output
[{'name': 'Carol', 'salary': 110000}, {'name': 'Alice', 'salary': 95000}, {'name': 'Bob', 'salary': 80000}, {'name': 'Dave', 'salary': 72000}]
Hints

Hint 1: itemgetter('salary') creates a callable that extracts the 'salary' key from a dict.

Hint 2: Use the reverse=True parameter to sort in descending order.

#3Case-Insensitive SortEasy
str.casefoldstring sortingUnicode

Sort a list of strings in case-insensitive alphabetical order. Use str.casefold instead of str.lower for proper Unicode handling.

Example:

words = ["Banana", "apple", "Cherry", "DATE", "elderberry"]
print(case_insensitive_sort(words))
# ['apple', 'Banana', 'Cherry', 'DATE', 'elderberry']
Solution
def case_insensitive_sort(words):
return sorted(words, key=str.casefold)

Explanation: str.casefold is a bound method that Python calls as a C-level function, avoiding the overhead of lambda s: s.lower(). It also handles Unicode edge cases: the German "ß" casefolds to "ss", while .lower() leaves it as "ß". Always prefer casefold for international text.

def case_insensitive_sort(words):
  """Sort strings case-insensitively.
  Use str.casefold for proper Unicode handling.

  Args:
      words: list of strings

  Returns:
      New sorted list (case-insensitive order)
  """
  # TODO: use sorted() with the right key
  pass
Expected Output
['apple', 'Banana', 'Cherry', 'DATE', 'elderberry']
Hints

Hint 1: str.casefold is a method that can be passed directly as a key function.

Hint 2: str.casefold handles Unicode better than str.lower — e.g., German sharp-s becomes 'ss'.

#4Sort by Vowel CountEasy
computed keylambdastring processing

Sort a list of programming language names by the number of vowels each contains (ascending). For words with the same vowel count, sort alphabetically (case-insensitive).

Example:

words = ["Python", "JavaScript", "Rust", "Go", "Erlang", "Lua", "C"]
print(sort_by_vowels(words))
# C=0, Go=1, Rust=1, Erlang=2, Lua=2, Python=2, JavaScript=3
# Tiebreakers: Go < Rust, Erlang < Lua < Python
# ['C', 'Go', 'Rust', 'Erlang', 'Lua', 'Python', 'JavaScript']
Solution
def sort_by_vowels(words):
def vowel_count(s):
return sum(1 for c in s.lower() if c in "aeiou")

return sorted(words, key=lambda w: (vowel_count(w), w.lower()))

Explanation: The tuple key (vowel_count(w), w.lower()) sorts primarily by vowel count. When two words have the same count (e.g., "Go" and "Rust" both have 1), Python compares the second tuple element w.lower() for alphabetical tiebreaking. The key function is called exactly once per element (n times total), so vowel_count is computed only once per word.

def sort_by_vowels(words):
  """Sort words by number of vowels (ascending).
  Tiebreaker: alphabetical order (case-insensitive).

  Args:
      words: list of strings

  Returns:
      New sorted list
  """
  # TODO: define a key that returns (vowel_count, word_lower)
  pass
Expected Output
['C', 'Go', 'Rust', 'Erlang', 'Lua', 'Python', 'JavaScript']
Hints

Hint 1: Count vowels with: sum(1 for c in s.lower() if c in 'aeiou').

Hint 2: Return a tuple (vowel_count, s.lower()) so Python sorts by vowels first, then alphabetically for ties.

#5Multi-Level Sort with Mixed DirectionsMedium
multi-key sortingtuple keyascending/descending

Sort a list of student dicts by grade ascending, then score descending, then name ascending.

Example:

students = [
{"name": "Alice", "grade": "A", "score": 95},
{"name": "Bob", "grade": "B", "score": 80},
{"name": "Carol", "grade": "A", "score": 95},
{"name": "Dave", "grade": "B", "score": 88},
{"name": "Eve", "grade": "A", "score": 88},
]
for s in sort_students(students):
print(s["name"], s["grade"], s["score"])
Solution
def sort_students(students):
return sorted(
students,
key=lambda s: (s["grade"], -s["score"], s["name"])
)

Explanation: The tuple key handles mixed directions: s["grade"] sorts ascending (A before B), -s["score"] sorts descending (95 before 88 because -95 is less than -88), and s["name"] sorts ascending as a final tiebreaker. This only works because score is numeric; you cannot negate strings. Alice and Carol both have grade A and score 95, so the name tiebreaker puts Alice first.

def sort_students(students):
  """Sort students by:
  1. Grade ascending (A before B)
  2. Score descending (highest first within grade)
  3. Name ascending (alphabetical tiebreaker)

  Args:
      students: list of dicts with 'name', 'grade', 'score'

  Returns:
      New sorted list
  """
  # TODO: use a tuple key with negation for descending fields
  pass
Expected Output
Alice A 95
Carol A 95
Eve A 88
Dave B 88
Bob B 80
Hints

Hint 1: For numeric fields, negate the value to reverse sort direction: -s['score'] sorts highest first.

Hint 2: For string fields that should be ascending, use them directly in the tuple.

Hint 3: The key tuple should be: (grade, -score, name).

#6Sort Dict by ValueMedium
dict sortingitemgetterfrequency count

Given a text string, count word frequencies and return the top n most frequent words as (word, count) tuples. Break ties alphabetically.

Example:

text = "the quick brown fox jumps over the lazy dog the fox"
print(top_words(text, 5))
# [('the', 3), ('fox', 2), ('brown', 1), ('dog', 1), ('jumps', 1)]
Solution
from operator import itemgetter

def top_words(text, n):
word_counts = {}
for word in text.lower().split():
word_counts[word] = word_counts.get(word, 0) + 1

ranked = sorted(
word_counts.items(),
key=lambda item: (-item[1], item[0])
)
return ranked[:n]

Explanation: We build a frequency dict, then sort .items() (which yields (word, count) tuples) with a key that negates the count for descending frequency and uses the word directly for ascending alphabetical tiebreaking. The [:n] slice returns only the top results. Note: we could use itemgetter(1) with reverse=True for the primary sort, but the tuple key with negation handles both criteria in a single pass.

from operator import itemgetter

def top_words(text, n):
  """Return the top n most frequent words.
  Tiebreaker: alphabetical order.

  Args:
      text: input string
      n: number of top words to return

  Returns:
      List of (word, count) tuples, most frequent first
  """
  # TODO: count words, sort by frequency desc then alpha asc, return top n
  pass
Expected Output
[('the', 3), ('fox', 2), ('brown', 1), ('dog', 1), ('jumps', 1)]
Hints

Hint 1: Build a frequency dict first: word_counts[word] = word_counts.get(word, 0) + 1.

Hint 2: Sort word_counts.items() with a tuple key: (-count, word) for descending frequency, ascending alpha.

Hint 3: Slice the sorted result with [:n] to get the top n.

#7Sort with None ValuesMedium
None handlingdefensive sortingtuple key

Sort a list of records by score. Some scores are None (pending). Place None values at the end or the beginning based on the none_position parameter.

Example:

records = [
{"name": "Alice", "score": 95},
{"name": "Bob", "score": None},
{"name": "Carol", "score": 88},
{"name": "Dave", "score": None},
{"name": "Eve", "score": 72},
]

print("--- None last ---")
for r in sort_with_none(records, "last"):
print(r["name"], r["score"])

print("--- None first ---")
for r in sort_with_none(records, "first"):
print(r["name"], r["score"])
Solution
def sort_with_none(records, none_position="last"):
if none_position == "last":
return sorted(
records,
key=lambda r: (
r["score"] is None,
r["score"] if r["score"] is not None else 0,
)
)
else:
return sorted(
records,
key=lambda r: (
r["score"] is not None,
r["score"] if r["score"] is not None else 0,
)
)

Explanation: The first tuple element is a boolean that partitions records into two groups. For "last": (False, score) for real scores and (True, 0) for None. Since False (0) sorts before True (1), non-None records come first. The second element sorts within each group. The fallback value 0 for None records does not matter since all None records share the same first element and compare only among themselves.

def sort_with_none(records, none_position="last"):
  """Sort records by score, handling None values.

  Args:
      records: list of dicts with 'name' and 'score' (score may be None)
      none_position: "last" or "first" — where None scores go

  Returns:
      New sorted list (non-None scores ascending, Nones at specified end)
  """
  # TODO: handle None safely using the (x is None, x) pattern
  pass
Expected Output
--- None last ---
Eve 72
Carol 88
Alice 95
Bob None
Dave None
--- None first ---
Bob None
Dave None
Eve 72
Carol 88
Alice 95
Hints

Hint 1: Python 3 raises TypeError when comparing None with int. You must avoid direct comparison.

Hint 2: Use the pattern: (x is None, x if x is not None else 0) — False sorts before True, so non-None values come first.

Hint 3: For 'first' position, flip the boolean: (x is not None, x if x is not None else 0).

#8Sort Objects with attrgetterMedium
attrgetterdataclassobject sorting

Sort a list of Product dataclass instances by category ascending, rating descending, and price ascending.

Example:

products = [
Product("Laptop", "Electronics", 699.99, 4.5),
Product("Blender", "Home", 49.99, 4.7),
Product("Phone", "Electronics", 999.99, 4.5),
Product("Headphones", "Electronics", 299.99, 4.8),
Product("Lamp", "Home", 89.99, 4.2),
]
for p in sort_products(products):
print(f"{p.category:13}{p.rating} ${p.price:<8} {p.name}")
Solution
from operator import attrgetter
from dataclasses import dataclass

@dataclass
class Product:
name: str
category: str
price: float
rating: float

def sort_products(products):
return sorted(
products,
key=lambda p: (p.category, -p.rating, p.price)
)

Explanation: We cannot use attrgetter alone here because we need to negate rating for descending order, which attrgetter cannot do. The lambda returns a tuple: p.category ascending, -p.rating descending (negation flips the order), p.price ascending. If all fields were ascending, attrgetter("category", "rating", "price") would be the faster choice.

An alternative using two stable sorts:

def sort_products(products):
result = sorted(products, key=attrgetter("price")) # tertiary
result.sort(key=attrgetter("rating"), reverse=True) # secondary
result.sort(key=attrgetter("category")) # primary
return result

This leverages Timsort stability: sorting by the least significant key first, then each more significant key, preserves the sub-ordering within groups.

from operator import attrgetter
from dataclasses import dataclass

@dataclass
class Product:
  name: str
  category: str
  price: float
  rating: float

def sort_products(products):
  """Sort products by category ascending, then rating descending,
  then price ascending.

  Use attrgetter where possible. For descending fields,
  you may need a lambda with negation.

  Args:
      products: list of Product instances

  Returns:
      New sorted list
  """
  # TODO: sort with multi-criteria key
  pass
Expected Output
Electronics  4.8  $299.99  Headphones
Electronics  4.5  $699.99  Laptop
Electronics  4.5  $999.99  Phone
Home         4.7  $49.99   Blender
Home         4.2  $89.99   Lamp
Hints

Hint 1: attrgetter('category', 'price') extracts multiple attributes as a tuple, but cannot negate them.

Hint 2: For mixed ascending/descending, use a lambda that returns a tuple: (p.category, -p.rating, p.price).

Hint 3: Alternatively, use two stable sorts: first by price ascending, then by rating descending within category.

#9Version String Sort with cmp_to_keyHard
cmp_to_keyfunctoolsversion comparison

Sort a list of semantic version strings in correct numeric order. "1.9.0" must sort before "1.10.0" (not after, as lexicographic sorting would place it). Handle versions with different numbers of segments.

Example:

versions = ["1.10.2", "1.9.0", "2.0.0", "1.2.3", "1.10.1",
"0.9.1", "10.0.0", "1.0.0", "1.2.0"]
print(sort_versions(versions))
# ['0.9.1', '1.0.0', '1.2.0', '1.2.3', '1.9.0', '1.10.1', '1.10.2', '2.0.0', '10.0.0']
Solution — Key Function (preferred)
def sort_versions(versions):
def version_key(v):
return tuple(int(x) for x in v.split("."))

return sorted(versions, key=version_key)

Explanation: Converting each segment to an integer and returning a tuple lets Python compare versions numerically: (1, 9, 0) is less than (1, 10, 0) because 9 < 10. Tuple comparison also handles different lengths: (1, 2) is less than (1, 2, 1) because when all shared elements are equal, the shorter tuple is "less than" the longer one.

Solution — cmp_to_key (comparator approach)
from functools import cmp_to_key

def sort_versions(versions):
def compare_versions(v1, v2):
parts1 = [int(x) for x in v1.split(".")]
parts2 = [int(x) for x in v2.split(".")]

for p1, p2 in zip(parts1, parts2):
if p1 < p2: return -1
if p1 > p2: return 1

return len(parts1) - len(parts2)

return sorted(versions, key=cmp_to_key(compare_versions))

Explanation: The comparator returns -1, 0, or 1 after comparing each segment pair. If all shared segments are equal, the longer version is greater. This works but is slower than the key function approach: the comparator is called O(n log n) times, while the key function is called only O(n) times. Always prefer the key function when the logic permits.

from functools import cmp_to_key

def sort_versions(versions):
  """Sort semantic version strings in ascending order.
  Handle versions with different segment counts:
  '1.2' < '1.2.1' < '1.10' < '2.0'

  Args:
      versions: list of version strings like '1.2.3'

  Returns:
      New sorted list
  """
  # TODO: implement using cmp_to_key OR a key function
  pass
Expected Output
['0.9.1', '1.0.0', '1.2.0', '1.2.3', '1.9.0', '1.10.1', '1.10.2', '2.0.0', '10.0.0']
Hints

Hint 1: A comparator function takes two args and returns negative (a < b), 0 (a == b), or positive (a > b).

Hint 2: Split each version on '.', convert to ints, compare segment by segment.

Hint 3: A simpler approach: use a key function that returns a tuple of ints — tuple comparison handles unequal lengths correctly.

#10Natural Sort (Mixed Text and Numbers)Hard
natural sortregexcmp_to_key

Implement natural sort order: strings containing numbers should sort numerically, not lexicographically. "file10.txt" should come after "file9.txt", not before.

Example:

files = [
"file1.txt", "file10.txt", "file2.txt", "file20.txt",
"file3.txt", "notes.txt", "README.md", "file9.txt"
]
print(natural_sort(files))
# ['README.md', 'file1.txt', 'file2.txt', 'file3.txt',
# 'file9.txt', 'file10.txt', 'file20.txt', 'notes.txt']
Solution
import re

def natural_sort(items):
def natural_key(s):
parts = re.split(r'(\d+)', s)
result = []
for part in parts:
if part.isdigit():
result.append((0, int(part), ''))
else:
result.append((1, 0, part.lower()))
return result

return sorted(items, key=natural_key)

Explanation: re.split(r'(\d+)', s) splits "file10.txt" into ["file", "10", ".txt"], keeping the digit groups. We convert digit parts to integers so 10 > 9 numerically. The wrapping into typed tuples (0, int_val, '') vs (1, 0, str_val) avoids TypeError when Python tries to compare an int with a string in the list. Text parts are lowercased for case-insensitive comparison.

A simpler version works when all items have the same text/number structure:

def natural_key_simple(s):
return [int(p) if p.isdigit() else p.lower()
for p in re.split(r'(\d+)', s)]

But this raises TypeError if Python ever compares an int with a str at the same list position. The tuple-wrapping approach is safe for all inputs.

import re

def natural_sort(items):
  """Sort strings containing numbers in natural order.
  'file9.txt' before 'file10.txt' (not lexicographic).
  Case-insensitive for text portions.

  Args:
      items: list of strings

  Returns:
      New sorted list in natural order
  """
  # TODO: split each string into text/number segments
  # Convert number segments to int for numeric comparison
  pass
Expected Output
['README.md', 'file1.txt', 'file2.txt', 'file3.txt', 'file9.txt', 'file10.txt', 'file20.txt', 'notes.txt']
Hints

Hint 1: Use re.split(r'(\d+)', s) to split a string into alternating text and number parts, keeping the delimiters.

Hint 2: Convert digit parts to int so that 9 < 10 (not '9' > '1' lexicographically).

Hint 3: Lowercase text parts for case-insensitive comparison.

#11E-Commerce Product Ranking SystemHard
real-worldmulti-criteriaproduction pattern

Build a product ranking function for an e-commerce search page. Products should be sorted by multiple business criteria: availability, membership tier, rating, social proof (review count), and price.

Example:

products = [
Product("Widget A", True, 4.5, 1200, 29.99, True),
Product("Widget B", False, 4.8, 300, 24.99, True),
Product("Widget C", True, 4.5, 3500, 34.99, False),
Product("Widget D", True, 4.3, 800, 19.99, True),
Product("Widget E", True, 4.8, 150, 39.99, True),
]
for i, p in enumerate(rank_products(products), 1):
stock = "IN " if p.in_stock else "OUT"
prime = "Prime" if p.is_prime else " "
print(f"{i}. [{stock}] {prime} {p.rating} ({p.review_count:>4} reviews) "
f"${p.price:.2f} {p.name}")
Solution
from dataclasses import dataclass
from typing import Optional

@dataclass
class Product:
name: str
in_stock: bool
rating: float
review_count: int
price: float
is_prime: bool

def rank_products(products):
def ranking_key(p):
return (
not p.in_stock, # False (0) before True (1) — in-stock first
not p.is_prime, # Prime items first
-round(p.rating, 1), # Higher rating first (round to avoid float noise)
-p.review_count, # More reviews first
p.price, # Lower price first
)

return sorted(products, key=ranking_key)

Explanation: Each tuple element maps to one business criterion. The boolean trick (not p.in_stock) works because False (0) sorts before True (1), so in-stock items appear first. Numeric negation reverses sort direction for rating and review count. Price is ascending since lower price is better.

The round(p.rating, 1) prevents float comparison noise: without rounding, 4.5000000001 and 4.4999999999 would sort differently despite being "the same" rating. In production, you might also add a time-decay factor to penalize stale ratings or a boost for sponsored products.

This named function approach is cleaner than a lambda for complex ranking logic: it is testable, documentable, and easy to modify when business requirements change.

from dataclasses import dataclass
from typing import Optional

@dataclass
class Product:
  name: str
  in_stock: bool
  rating: float
  review_count: int
  price: float
  is_prime: bool

def rank_products(products):
  """Rank products for an e-commerce search results page.

  Ranking criteria (highest priority first):
  1. In-stock items before out-of-stock
  2. Prime items before non-Prime
  3. Higher rating first
  4. More reviews first (proxy for trust)
  5. Lower price first (tiebreaker)

  Args:
      products: list of Product instances

  Returns:
      New sorted list (best rank first)
  """
  # TODO: define a ranking key function and sort
  pass
Expected Output
1. [IN ] Prime  4.8 ( 150 reviews) $39.99  Widget E
2. [IN ] Prime  4.5 (1200 reviews) $29.99  Widget A
3. [IN ]        4.5 (3500 reviews) $34.99  Widget C
4. [IN ] Prime  4.3 ( 800 reviews) $19.99  Widget D
5. [OUT] Prime  4.8 ( 300 reviews) $24.99  Widget B
Hints

Hint 1: Use 'not p.in_stock' so that False (in-stock) sorts before True (out-of-stock).

Hint 2: Negate numeric fields for descending: -p.rating, -p.review_count.

Hint 3: Price is ascending (lower is better), so use p.price directly.

Hint 4: Return a 5-element tuple as the key: (not in_stock, not is_prime, -rating, -review_count, price).

© 2026 EngineersOfAI. All rights reserved.