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
- sortedBy calls
sortedWith(compareBy(selector))internally — it builds aComparatorfrom your selector and delegates to a stable sort. The selector runs during comparisons, roughlyO(n log n)times for a list of sizen, 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. - 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’sO(n)in the number of elements. - The returned map from
groupBypreserves insertion order because it’s backed by aLinkedHashMap— this is a deliberate, documented guarantee, not an implementation accident, so it’s safe to depend on for deterministic output and tests. - groupingBy().eachCount() skips building any lists at all: it walks the source once, computes a key per element, and increments an
Intcounter for that key in a map, which is both faster and more memory-efficient thangroupBy(...).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/groupByover 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 separatesortedBycalls — 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()overgroupBy(...).mapValues { ... }— it avoids materializing throwaway per-group lists. - Keep selector lambdas cheap and side-effect free;
sortedBy‘s selector runs on the order ofn log ntimes during the sort, not once per element, so expensive selectors add up fast. - Remember that
groupBy‘s key order (first-seen order) andsortedBy‘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: aval list = mutableListOf(...)can still have its contents changed bysortBy,add, orremoveeven thoughlistitself 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 byratingdescending, breaking ties bytitleascending. (Hint:sortedWith(compareByDescending<Movie> { it.rating }.thenBy { it.title }).) - Given
val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10), usegroupByto 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
sortedByreturns a new, ascending-sortedListderived from a key selector; it never mutates the source and works on anyIterable.sortByis the in-place counterpart, available only onMutableList— a read-onlyListreference 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. groupBypartitions a collection into aMap<K, List<T>>in a single pass, preserving first-seen key order via aLinkedHashMap.associateBylooks 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
sortedWithwithcompareBy/thenBy/thenByDescendingfor clean, composable multi-key comparators.
