1. Why grouping is needed
When there is more data, a plain list starts behaving like a closet without shelves: the items are there, but finding “all T-shirts” among “everything mixed together” is hard. Grouping is a way to take a flat list and distribute elements into “buckets” (groups) by some criterion: by category, by first letter, by string length, by year, and so on.
In Kotlin, grouping is most often done with the groupBy function. The idea is simple: for each element, a grouping key is computed, and the elements are collected into a map: key → list of elements that produced that key.
Imagine you have expenses:
- "food" 120 "lunch"
- "taxi" 250 "airport"
- "food" 80 "coffee"
You want a structure like:
- food → [...all food expenses...]
- taxi → [...all taxi expenses...]
That is exactly the result of groupBy.
2. groupBy: keys and the result Map<K, List<T>>
In “grown-up” terms, groupBy does two things: it computes a key for each element and it collects elements into groups by that key. It’s important to accept the main idea up front: the result is not Map<K, T>, but specifically Map<K, List<T>>, because there can be many elements with the same key.
The result shape looks like this:
- input: List<T>
- output: Map<K, List<T>>
Where K is the key type produced by your lambda (for example, Int, Char, String), and T is the element type of the original list.
A small example in words (the “cleanest” one, without our project):
fun main() {
val words = listOf("one", "two", "three", "four")
val byLength: Map<Int, List<String>> = words.groupBy { it.length }
println(byLength) // {3=[one, two], 5=[three], 4=[four]}
}
Here the key is the word length (Int), and the value is the list of words of that length.
And yes, groupBy is a standard and very “canonical” Kotlin-style technique; it’s often presented as a basic tool for working with collections.
Why the value is a List
A moment that makes beginners go “wait…”: why doesn’t Kotlin return something like Map<K, Set<T>>? Why specifically a List?
The answer is practical: groupBy keeps all elements and keeps them in the order of the original list. That means within each group, the order will match the order in which elements appeared in the original. This is convenient because often you’ve already sorted the source data, or insertion order matters, or you just want predictable output.
Also, List is the “softest” container. It doesn’t require uniqueness (a group can contain identical elements), it doesn’t force you to think about hashing, and in general it matches the idea of “collect everything as-is, we’ll deal with it later.”
If you need a different container (for example, a Set), that’s the next processing step: first group, then transform. But today we’re practicing how to obtain and read the basic form Map<K, List<T>>.
How Kotlin decides what counts as “the same”
The grouping key is the result of your keySelector lambda, i.e., the expression inside the curly braces. Kotlin computes it for each element and uses it as the “bucket label” where the element will go.
Example: group words by the first letter.
fun main() {
val words = listOf("one", "two", "three", "four")
val byFirstChar: Map<Char, List<String>> = words.groupBy { it[0] }
for ((ch, group) in byFirstChar) {
println("$ch -> $group")
}
// o -> [one]
// t -> [two, three]
// f -> [four]
}
Notice that the key type here is Char. Kotlin infers it from the expression it[0].
And here comes the first “grown-up” caution: if a string can be empty, it[0] will crash with an error. So when grouping by characters, you almost always filter out empty strings first.
3. How to read the groupBy result
After groupBy you get a Map. Which means you read the result in exactly the same ways as any other map: by iterating over entries (or by destructuring in a for loop), or by accessing by key.
A key detail to remember: Map stores key-value pairs, keys are unique, and lookup by key returns a nullable value because the key might be absent. This directly affects how to read groups safely.
The nicest way is to just iterate over all groups:
fun main() {
val words = listOf("one", "two", "three", "four")
val groups = words.groupBy { it.length }
for ((len, group) in groups) {
println("len=$len, count=${group.size}, items=$group")
// len=3, count=2, items=[one, two]
// len=5, count=1, items=[three]
// len=4, count=1, items=[four]
}
}
Here group is a regular List<String>. It has size, you can iterate it, sort it (if you really need to), build strings from it, and so on.
If you want to take a “specific” group by key, remember the nullable:
fun main() {
val words = listOf("one", "two", "three", "four")
val groups = words.groupBy { it.length }
val len4: List<String> = groups[4] ?: emptyList()
println(len4) // [four]
}
This is one of the calmest patterns: “if there is no group, consider it empty.” It’s especially handy in reports.
4. BudgetBuddy: group expenses by categories
Now let’s do what all this was for: the “expenses by categories” report. Here it’s important not to jump into space, but to carefully connect knowledge from previous topics with the new function.
We have a list of expenses:
- category: String
- amount: Int (for now integers, so we don’t argue about cents and Double)
- note: String
Let’s describe the record type as a Triple and prepare test data:
fun main() {
val expenses: List<Triple<String, Int, String>> = listOf(
Triple("food", 120, "lunch"),
Triple("taxi", 250, "airport"),
Triple("food", 80, "coffee"),
Triple("books", 500, "kotlin book")
)
val byCategory = expenses.groupBy { it.first }
println(byCategory.keys) // [food, taxi, books]
}
The key here is it.first, i.e., the category. And the value is the list of triples that belong to that category.
It’s useful to notice how quickly it “feels” easier to work: instead of one long list, we now have a structure where the data is already “put on the shelves.”
Printing a report by groups
A good report is not just println(map) (though sometimes even that is already happiness). Usually you want block output: first the category, then the items inside it.
Let’s make a “section” printing function. Notice: we’re not doing complex architecture and we’re not diving into future topics; we’re just carefully using groupBy and loops.
fun printByCategoryReport(expenses: List<Triple<String, Int, String>>) {
val groups = expenses.groupBy { it.first }
for ((category, items) in groups) {
println("== $category ==")
for ((_, amount, note) in items) {
println("- $amount : $note")
}
println()
}
}
And an example call:
fun main() {
val expenses = listOf(
Triple("food", 120, "lunch"),
Triple("taxi", 250, "airport"),
Triple("food", 80, "coffee")
)
printByCategoryReport(expenses)
// == food ==
// - 120 : lunch
// - 80 : coffee
//
// == taxi ==
// - 250 : airport
}
Pay attention to a small UX detail: we added an empty line between categories. It’s a small thing, but it turns a “log” into a “report.”
5. Useful nuances of groupBy
Key normalization
When you first introduce grouping into a real application, you suddenly find out that users (and you, too) enter categories however they feel like: "Food", " food ", "FOOD". If you group “as-is,” you’ll get three groups instead of one. And that’s not a groupBy bug; it’s an honest result: the keys are different.
That’s why the grouping key is often normalized: trim() + lowercase().
fun normalizeCategory(raw: String): String {
return raw.trim().lowercase()
}
fun main() {
val expenses = listOf(
Triple(" Food ", 120, "lunch"),
Triple("food", 80, "coffee"),
Triple("FOOD", 200, "dinner")
)
val groups = expenses.groupBy { normalizeCategory(it.first) }
println(groups.keys) // [food]
}
Notice the idea: normalization is part of keySelector. That is, we don’t “fix it later”; we agree up front that keys are computed by meaning.
groupBy(keySelector, valueTransform)
Sometimes you don’t need “full records” inside the group. For example, for a category report you may want to store only amounts (amount) inside the group to compute aggregates later. Or only notes. Or, for example, only “human-readable text” ready to print.
For these cases, groupBy has a form with two lambdas: one defines the key, and the second transforms the element before putting it into the group.
The idea sounds like: “group by key, but in each group store not T, but V.”
Example: group expenses by category, but put only amount inside.
fun main() {
val expenses = listOf(
Triple("food", 120, "lunch"),
Triple("taxi", 250, "airport"),
Triple("food", 80, "coffee")
)
val amountsByCategory: Map<String, List<Int>> = expenses.groupBy(
keySelector = { it.first },
valueTransform = { it.second }
)
println(amountsByCategory) // {food=[120, 80], taxi=[250]}
}
This is still Map<K, List<...>>, but now the list contains a different type (Int), not the original triple.
Likewise, you can store preformatted strings for printing:
fun main() {
val expenses = listOf(
Triple("food", 120, "lunch"),
Triple("food", 80, "coffee")
)
val linesByCategory = expenses.groupBy(
keySelector = { it.first },
valueTransform = { (_, amount, note) -> "$amount : $note" }
)
println(linesByCategory["food"])
// [120 : lunch, 80 : coffee]
}
This technique is great when you want to make output simpler and not carry “heavy” structures further down the pipeline.
Mini diagram: what groupBy does
To lock in the “result shape,” it helps to see it once as a process. This is not magic and not a “special data structure”; it’s a very understandable reshuffling of elements.
flowchart TD
A["List⟨T⟩ (flat list)"] -->|for each element compute key| B["key K"]
B --> C["Map⟨K, List⟨T⟩⟩"]
A -->|element is added to the list of its group| C
That is, groupBy does not “lose elements” and does not “compute statistics.” It specifically distributes elements into groups, keeping them as lists.
6. Typical mistakes when working with groupBy
Mistake No. 1: expecting Map<K, V>, but getting Map<K, List<V>>.
This is the most common confusion. A beginner’s brain sometimes wants “category → one expense,” but grouping by definition produces “category → many expenses.” If you truly need one element per key, that’s a different task (and a different tool). For groupBy, it helps to repeat mentally: “a group is a list.”
Mistake No. 2: grouping by it[0] without making sure the string is not empty.
groupBy is not to blame here: you’re the one accessing the zero-th character. In real input, empty strings appear all the time (double spaces, empty commands, accidental Enter). Discipline saves you: filter out empty values before grouping, or choose a key that doesn’t require risky index access.
Mistake No. 3: getting “duplicate” categories because of spaces and case.
Categories "Food", " food ", and "FOOD" are different strings, therefore different keys, therefore different groups. When this shows up in a report, it can feel like “groupBy is broken,” but in reality you just didn’t normalize the key. Usually trim().lowercase() right inside keySelector is enough.
Mistake No. 4: trying to read a group via groups[key]!! and crashing.
Even if “you’re sure the key exists,” code changes over time, input data gets messier, and then you catch a NullPointerException because of !!. It’s much calmer to either iterate the whole map via for ((k, v) in groups), or use groups[key] ?: emptyList().
Mistake No. 5: printing println(groups) and expecting a “nice report.”
println(map) is a quick way to see that the structure was produced, and it’s fine for debugging. But in a real program it turns the output into a “curly-brace mess.” Better to make a small formatted output: category as a heading, then group items line by line. That’s literally 10 lines of code, and readability improves by an order of magnitude.