sortedBy and groupBy

Sorting and grouping are two of the most common things you do with a collection: put its elements in some order, or bucket them by a shared trait. Kotlin’s standard library gives you sortedBy and its relatives for ordering, and groupBy for partitioning a list into a Map keyed by whatever you choose. Both are higher-order functions — you hand them a small lambda describing what to sort or group by, and Kotlin does the rest without a manual loop, a hand-written Comparator, or a hand-rolled HashMap. If you’re coming from Java, this replaces a lot of ceremony: no more anonymous Comparator<T> classes and no more manually checking map.containsKey(...) before appending to a bucket.

Overview / How sortedBy and groupBy Work

sortedBy is an extension function available on any Iterable<T> (which every List, Set, and similar collection implements). You give it a selector lambda that extracts a Comparable key from each element — an Int, a String, an enum, anything that implements Comparable — and it returns a brand-new List<T> sorted ascending by that key. The original collection is never touched. sortedByDescending does the same thing in reverse order. Under the hood, on the JVM this ultimately calls into a stable merge sort (Timsort), which means elements that compare equal keep their original relative order — this matters more than it sounds, since it’s what lets you do multi-key sorting and get correct, predictable results.

There is a mutating sibling, sortBy, which only exists on MutableList<T>. It sorts the list in place and returns Unit instead of a new list. This is a meaningful distinction: a plain List reference (Kotlin’s read-only collection interface) does not even expose sortBy — the compiler refuses to resolve it. Reach for sortBy only when you already hold a genuine MutableList and actually want to mutate it; otherwise sortedBy, which works on any Iterable and hands back an independent list, is almost always the right default.

val mutableWords = mutableListOf("banana", "kiwi", "apple")
mutableWords.sortBy { it.length }   // sorts IN PLACE, returns Unit
println(mutableWords)               // [kiwi, apple, banana]

val readOnlyWords: List<String> = listOf("banana", "kiwi", "apple")
val newList = readOnlyWords.sortedBy { it.length } // returns a NEW list
println(newList)                    // [kiwi, apple, banana]
println(readOnlyWords)              // [banana, kiwi, apple] -- untouched

When you need to sort by more than one key — say, department first and salary second — chaining sortedBy calls is fragile and hard to read. Use sortedWith together with the compareBy/thenBy/thenByDescending comparator builders instead; they compose into a single Comparator<T> that Kotlin evaluates left to right, falling through to the next key only when the previous one ties.

groupBy solves a different problem: instead of ordering elements, it partitions them. You give it a key selector lambda, and it walks the source collection once, computing a key for every element and appending that element to a bucket for that key. The result is a Map<K, List<T>> backed by a LinkedHashMap, so the keys appear in the order they were first encountered — not sorted, not random, but exactly the order the first member of each group showed up in the source. There is also a two-argument overload, groupBy(keySelector, valueTransform), which lets you store a transformed value instead of the original element — useful when you only need part of each item once it’s grouped.

If all you need is a count or a fold per group — not the actual grouped elements — building the intermediate Map<K, List<T>> is wasteful. groupingBy { ... } returns a lazy Grouping object with operations like eachCount(), fold(...), and aggregate(...) that compute the result in a single pass without ever materializing the per-group lists.

Syntax

The general shapes you’ll use most often:

Function Returns Notes
list.sortedBy { selector } new List<T> ascending, stable, non-mutating
list.sortedByDescending { selector } new List<T> descending, stable, non-mutating
mutableList.sortBy { selector } Unit in place, only on MutableList
list.sortedWith(comparator) new List<T> custom/multi-key ordering via compareBy/thenBy
list.groupBy { keySelector } Map<K, List<T>> preserves first-seen key order
list.groupBy({ key }, { value }) Map<K, List<V>> stores transformed values instead of elements
list.groupingBy { key }.eachCount() Map<K, Int> single-pass counting, no intermediate lists

The (simplified) standard-library declarations look like this:

// sortedBy: ascending order by a derived key
inline fun <T, R : Comparable<R>> Iterable<T>.sortedBy(
    crossinline selector: (T) -> R?
): List<T>

// groupBy: partition elements into a Map keyed by a derived key
inline fun <T, K> Iterable<T>.groupBy(
    keySelector: (T) -> K
): Map<K, List<T>>

Notice the selector in sortedBy returns R? — a nullable key is allowed, and null keys sort first. The groupBy key selector’s K has no Comparable bound at all, because grouping doesn’t need to order anything, only to compute equal/unequal keys via equals() and hashCode().

Examples

Example 1: Sorting strings by length

fun main() {
    val words = listOf("banana", "kiwi", "apple", "fig", "cherry")
    val byLength = words.sortedBy { it.length }
    println(byLength)
}
Output:
[fig, kiwi, apple, banana, cherry]

The selector { it.length } is invoked as needed while the sort compares elements. fig (3) comes first, then kiwi (4), then apple (5). banana and cherry are tied at length 6 — because the sort is stable, they keep their original relative order, with banana (which appeared earlier in the source list) staying ahead of cherry.

Example 2: Grouping words by first letter

fun main() {
    val words = listOf("banana", "kiwi", "apple", "fig", "cherry", "blueberry", "avocado")
    val byFirstLetter = words.groupBy { it.first() }
    println(byFirstLetter)
}
Output:
{b=[banana, blueberry], k=[kiwi], a=[apple, avocado], f=[fig], c=[cherry]}

it.first() returns the first Char of each string. The resulting map’s key order is b, k, a, f, c — the order in which each letter was first seen while scanning the list, not alphabetical. Within each bucket, elements appear in their original relative order too: blueberry lands after banana in the b bucket because that’s the order they occur in the source list.

Example 3: Sorting and grouping employee records

data class Employee(val name: String, val department: String, val salary: Int)

fun main() {
    val employees = listOf(
        Employee("Alice", "Engineering", 95000),
        Employee("Bob", "Sales", 65000),
        Employee("Carol", "Engineering", 105000),
        Employee("Dave", "Sales", 70000),
        Employee("Eve", "Marketing", 80000)
    )

    val byDeptThenSalaryDesc = employees.sortedWith(
        compareBy<Employee> { it.department }.thenByDescending { it.salary }
    )
    byDeptThenSalaryDesc.forEach { println("${it.department}: ${it.name} - ${it.salary}") }

    println()

    val grouped = employees.groupBy { it.department }
    grouped.forEach { (dept, list) ->
        println("$dept: ${list.map { it.name }}")
    }

    println()

    val avgSalaryByDept = employees.groupBy { it.department }
        .mapValues { (_, list) -> list.map { it.salary }.average() }
    println(avgSalaryByDept)
}
Output:
Engineering: Carol - 105000
Engineering: Alice - 95000
Marketing: Eve - 80000
Sales: Dave - 70000
Sales: Bob - 65000

Engineering: [Alice, Carol]
Sales: [Bob, Dave]
Marketing: [Eve]

{Engineering=100000.0, Sales=67500.0, Marketing=80000.0}

Employee is a data class, so it gets a readable auto-generated toString() and structural equals()/hashCode() for free. compareBy<Employee> { it.department }.thenByDescending { it.salary } builds one Comparator that orders by department alphabetically, then — only among employees in the same department — by salary from highest to lowest. groupBy { it.department } then partitions the same list into a map of department to the employees in it, and mapValues transforms each bucket’s list of employees into a single average salary, producing a compact department-to-average-salary summary in one chained expression.

How It Works Step by Step

  1. sortedBy calls sortedWith(compareBy(selector)) internally — it builds a Comparator from your selector and delegates to a stable sort. The selector runs during comparisons, roughly O(n log n) times for a list of size n, not once per element. If your selector does expensive work, consider precomputing the keys first, since a costly selector called repeatedly can dominate the sort’s runtime.
  2. groupBy, by contrast, makes exactly one pass over the source and calls the key selector exactly once per element. It builds a LinkedHashMap<K, MutableList<T>>, and for each element looks up (or creates) the list for that element’s key and appends to it, so it’s O(n) in the number of elements.
  3. The returned map from groupBy preserves insertion order because it’s backed by a LinkedHashMap — this is a deliberate, documented guarantee, not an implementation accident, so it’s safe to depend on for deterministic output and tests.
  4. groupingBy().eachCount() skips building any lists at all: it walks the source once, computes a key per element, and increments an Int counter for that key in a map, which is both faster and more memory-efficient than groupBy(...).mapValues { it.value.size } when you only need the counts.
fun main() {
    val letters = "mississippi".toList()
    val counts = letters.groupingBy { it }.eachCount()
    println(counts)
}
Output:
{m=1, i=4, s=4, p=2}

toList() turns the String into a List<Char>, and groupingBy { it } uses each character itself as the key. eachCount() then produces a frequency map in a single pass, with keys ordered by first appearance: m, then i, then s, then p.

Common Mistakes

Mistake 1: Calling sortBy on a read-only List

sortBy only exists on MutableList<T>. If your variable’s declared type is the read-only List<T> — even if the underlying object happens to be mutable — the compiler won’t resolve sortBy on it at all.

val words: List<String> = listOf("banana", "kiwi", "apple")
words.sortBy { it.length }
println(words)
Compiler error:
unresolved reference: sortBy -- 'sortBy' is only declared as an extension on kotlin.collections.MutableList<T>, and 'words' has type List<String>.

The fix is almost always to stop trying to mutate and use sortedBy instead, which works on any Iterable and gives you a new, correctly sorted list without needing a MutableList in the first place:

fun main() {
    val words: List<String> = listOf("banana", "kiwi", "apple")
    val sorted = words.sortedBy { it.length }
    println(sorted)
}
Output:
[kiwi, apple, banana]

Mistake 2: Reaching for associateBy when you actually want groupBy

associateBy looks superficially similar to groupBy — both take a key selector and build a Map — but associateBy produces Map<K, T> (a single value per key), not Map<K, List<T>>. If two elements produce the same key, the later one silently overwrites the earlier one, with no warning or error.

fun main() {
    val names = listOf("Ann", "Bob", "Al", "Ben", "Cy")
    val byFirstLetter = names.associateBy { it.first() }
    println(byFirstLetter)
}
Output:
{A=Al, B=Ben, C=Cy}

Ann and Bob are gone — Al overwrote Ann‘s entry for key A, and Ben overwrote Bob‘s entry for key B, because associateBy only ever keeps the last value seen for each key. That’s the right tool when you know keys are unique (say, building an id-to-object lookup table), but it’s the wrong one when duplicates are expected and you need all of them. groupBy keeps every element:

fun main() {
    val names = listOf("Ann", "Bob", "Al", "Ben", "Cy")
    val byFirstLetter = names.groupBy { it.first() }
    println(byFirstLetter)
}
Output:
{A=[Ann, Al], B=[Bob, Ben], C=[Cy]}

Best Practices

  • Default to sortedBy/groupBy over their mutating or single-value cousins (sortBy, associateBy) unless you specifically need in-place mutation or a unique-key lookup — accidentally dropping data is a much worse bug than allocating one extra list.
  • For more than one sort key, use sortedWith(compareBy{...}.thenBy{...}.thenByDescending{...}) rather than chaining several separate sortedBy calls — it’s clearer and avoids relying on subtle stability interactions between passes.
  • When you only need aggregated results per group (counts, sums, min/max), prefer groupingBy { }.eachCount()/fold()/aggregate() over groupBy(...).mapValues { ... } — it avoids materializing throwaway per-group lists.
  • Keep selector lambdas cheap and side-effect free; sortedBy‘s selector runs on the order of n log n times during the sort, not once per element, so expensive selectors add up fast.
  • Remember that groupBy‘s key order (first-seen order) and sortedBy‘s stability are both documented, deterministic guarantees — you can rely on them in tests instead of treating output order as arbitrary.
  • Watch for the val-doesn’t-mean-immutable trap: a val list = mutableListOf(...) can still have its contents changed by sortBy, add, or remove even though list itself can never be reassigned.

Practice Exercises

  • Define data class Movie(val title: String, val year: Int, val rating: Double), build a list of at least five movies, and produce a list sorted by rating descending, breaking ties by title ascending. (Hint: sortedWith(compareByDescending<Movie> { it.rating }.thenBy { it.title }).)
  • Given val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10), use groupBy to partition them into even and odd. Expected output: {false=[1, 3, 5, 7, 9], true=[2, 4, 6, 8, 10]}.
  • Given a list of words of varying length, use groupingBy { it.length }.eachCount() to find how many words exist for each length, then use the resulting map to print the single most common word length.

Summary

  • sortedBy returns a new, ascending-sorted List derived from a key selector; it never mutates the source and works on any Iterable.
  • sortBy is the in-place counterpart, available only on MutableList — a read-only List reference won’t even compile against it.
  • Sorting is stable: elements with equal keys retain their original relative order, which is what makes thenBy-style multi-key sorting predictable.
  • groupBy partitions a collection into a Map<K, List<T>> in a single pass, preserving first-seen key order via a LinkedHashMap.
  • associateBy looks similar but keeps only the last element per key — use it for unique-key lookups, not for grouping duplicates.
  • groupingBy { }.eachCount()/fold()/aggregate() compute per-group aggregates in one pass without building intermediate lists.
  • Use sortedWith with compareBy/thenBy/thenByDescending for clean, composable multi-key comparators.