Scala - sortBy and sortWith

The `sortBy` method transforms each element into a comparable value and sorts based on that extracted value. This approach works seamlessly with any type that has an implicit `Ordering` instance.

Key Insights

  • sortBy extracts a comparable value from each element for sorting, while sortWith uses a custom comparison function for fine-grained control over ordering logic
  • sortBy is more concise for simple cases and leverages implicit Ordering instances, whereas sortWith provides explicit boolean comparisons for complex multi-field sorting
  • Both methods are stable sorts that preserve relative ordering of equal elements, with O(n log n) time complexity using TimSort under the hood

Understanding sortBy: Extraction-Based Sorting

The sortBy method transforms each element into a comparable value and sorts based on that extracted value. This approach works seamlessly with any type that has an implicit Ordering instance.

case class Employee(name: String, salary: Int, department: String)

val employees = List(
  Employee("Alice", 75000, "Engineering"),
  Employee("Bob", 65000, "Sales"),
  Employee("Charlie", 85000, "Engineering"),
  Employee("Diana", 70000, "Marketing")
)

// Sort by salary (ascending)
val bySalary = employees.sortBy(_.salary)
// Result: Bob (65000), Diana (70000), Alice (75000), Charlie (85000)

// Sort by name (alphabetically)
val byName = employees.sortBy(_.name)
// Result: Alice, Bob, Charlie, Diana

// Sort by salary descending
val bySalaryDesc = employees.sortBy(_.salary)(Ordering[Int].reverse)
// Result: Charlie (85000), Alice (75000), Diana (70000), Bob (65000)

The power of sortBy lies in its simplicity. The method signature is sortBy[B](f: A => B)(implicit ord: Ordering[B]), meaning you provide a function that extracts a sortable value, and Scala handles the comparison automatically.

Sorting with Multiple Criteria Using sortBy

For multi-field sorting, sortBy accepts tuples. Scala compares tuples lexicographically—first by the first element, then by the second if the first elements are equal, and so on.

// Sort by department, then by salary within each department
val byDeptAndSalary = employees.sortBy(e => (e.department, e.salary))

// Sort by department ascending, salary descending
val byDeptAscSalaryDesc = employees.sortBy(e => 
  (e.department, -e.salary)
)

// More complex: department ascending, salary descending
val complexSort = employees.sortBy(e => 
  (e.department, e.salary)
)(Ordering.Tuple2(Ordering[String], Ordering[Int].reverse))

The tuple approach works well for up to 3-4 fields. Beyond that, readability suffers and sortWith becomes preferable.

Working with Option Types in sortBy

Handling Option values requires careful consideration of how None should be ordered relative to Some values.

case class Product(name: String, price: Option[Double])

val products = List(
  Product("Laptop", Some(999.99)),
  Product("Mouse", Some(25.50)),
  Product("TBD Item", None),
  Product("Keyboard", Some(75.00))
)

// Default Option ordering: None < Some
val sorted1 = products.sortBy(_.price)
// Result: TBD Item (None), Mouse (25.50), Keyboard (75.00), Laptop (999.99)

// Put None values last
val sorted2 = products.sortBy(_.price)(
  Ordering.Option(Ordering[Double]).reverse.reverse
)

// More explicit: use getOrElse for control
val sorted3 = products.sortBy(_.price.getOrElse(Double.MaxValue))
// Result: Mouse, Keyboard, Laptop, TBD Item

Understanding sortWith: Comparison-Based Sorting

The sortWith method takes a comparison function (A, A) => Boolean that returns true if the first element should come before the second. This provides complete control over comparison logic.

// Sort employees by salary descending
val salaryDesc = employees.sortWith(_.salary > _.salary)

// Sort by name length, then alphabetically for same length
val byNameLength = employees.sortWith { (e1, e2) =>
  if (e1.name.length != e2.name.length)
    e1.name.length < e2.name.length
  else
    e1.name < e2.name
}
// Result: Bob (3), Alice (5), Diana (5), Charlie (7)

The comparison function must be consistent and transitive. If f(a, b) returns true and f(b, c) returns true, then f(a, c) should also return true.

Complex Multi-Field Sorting with sortWith

When sorting logic becomes intricate, sortWith offers better readability than nested tuple orderings.

case class Transaction(
  date: java.time.LocalDate, 
  amount: Double, 
  category: String,
  priority: Int
)

val transactions = List(
  Transaction(java.time.LocalDate.of(2024, 1, 15), 100.0, "Food", 1),
  Transaction(java.time.LocalDate.of(2024, 1, 15), 200.0, "Food", 2),
  Transaction(java.time.LocalDate.of(2024, 1, 10), 150.0, "Transport", 1)
)

// Sort by: date desc, priority asc, amount desc
val complexSort = transactions.sortWith { (t1, t2) =>
  if (t1.date != t2.date)
    t1.date.isAfter(t2.date)
  else if (t1.priority != t2.priority)
    t1.priority < t2.priority
  else
    t1.amount > t2.amount
}

This approach scales better than tuple-based sorting and makes the sorting criteria explicit and maintainable.

Performance Considerations and Gotchas

Both methods use TimSort, which performs well on partially sorted data with O(n log n) worst-case complexity. However, there are important performance characteristics to understand.

// sortBy creates intermediate objects for extraction
val large = (1 to 1000000).map(i => Employee(s"Name$i", i, s"Dept${i % 10}"))

// This creates 1M tuples in memory
val sorted1 = large.sortBy(e => (e.department, e.salary))

// sortWith avoids intermediate allocations
val sorted2 = large.sortWith { (e1, e2) =>
  if (e1.department != e2.department)
    e1.department < e2.department
  else
    e1.salary < e2.salary
}

For large collections, sortWith can be more memory-efficient since it doesn’t create intermediate values. However, the difference is often negligible unless dealing with millions of elements.

Custom Ordering Instances

For frequently used sorting patterns, define custom Ordering instances rather than repeating logic.

object EmployeeOrderings {
  implicit val byDepartmentThenSalary: Ordering[Employee] = 
    Ordering.by(e => (e.department, e.salary))
  
  val bySalaryDesc: Ordering[Employee] = 
    Ordering.by[Employee, Int](_.salary).reverse
  
  val byNameCaseInsensitive: Ordering[Employee] =
    Ordering.by(_.name.toLowerCase)
}

// Usage
import EmployeeOrderings._

val sorted1 = employees.sorted // uses implicit ordering
val sorted2 = employees.sorted(bySalaryDesc)
val sorted3 = employees.sorted(byNameCaseInsensitive)

This approach promotes reusability and keeps sorting logic centralized.

Choosing Between sortBy and sortWith

Use sortBy when:

  • Sorting by a single field or simple tuple of fields
  • The extraction function is straightforward
  • You want concise, readable code

Use sortWith when:

  • Comparison logic involves complex conditions
  • You need maximum performance on large collections
  • The sorting criteria don’t map cleanly to extractable values
  • You’re implementing custom comparison algorithms
// Prefer sortBy for simple cases
val simple = employees.sortBy(_.salary)

// Prefer sortWith for complex logic
val complex = employees.sortWith { (e1, e2) =>
  val salaryDiff = math.abs(e1.salary - e2.salary)
  if (salaryDiff < 5000) // similar salaries
    e1.name < e2.name
  else
    e1.salary > e2.salary
}

Both methods are stable sorts, preserving the relative order of elements that compare as equal. Understanding when to use each method leads to cleaner, more maintainable Scala code.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.