Kotlin Collection: map, filter and groupBy with practical examples
1. Overview
Most business logic transforms lists: extracting a few fields, filtering by conditions, grouping to calculate totals. Kotlin provides the extension functions map, filter, and groupBy in its standard library for this purpose, so you don’t need any additional libraries.
This article covers each function and its important variants, then combines them into a complete pipeline. At the end, there’s a test to verify correctness and a list of common mistakes.
2. Sample data
All examples in this article use the same sample list of orders:
enum class Status { NEW, PAID, CANCELLED }
data class Order(
val id: Int,
val customer: String,
val status: Status,
val total: Int, // unit: thousands of đồng
val coupon: String? = null
)
val orders = listOf(
Order(1, "An", Status.PAID, 120, coupon = "SALE10"),
Order(2, "Bình", Status.NEW, 80),
Order(3, "An", Status.CANCELLED, 50),
Order(4, "Chi", Status.PAID, 200, coupon = "VIP"),
Order(5, "Bình", Status.PAID, 45),
)
orders has type List<Order>, which is an immutable list. The functions below do not modify the original list. Each call returns a new collection.
3. map: Transform each element
3.1. Basic map
map takes a lambda (T) -> R, applies it to each element, and returns a List<R>:
val ids = orders.map { it.id }
val customers = orders.map { it.customer }
println(ids)
println(customers)
[1, 2, 3, 4, 5]
[An, Bình, An, Chi, Bình]
The result always has the same number of elements as the original list and preserves order. If you want only unique values, append .distinct(): orders.map { it.customer }.distinct() returns [An, Bình, Chi].
For a simple property, you can use a property reference instead of a lambda: orders.map(Order::id).
3.2. mapNotNull: Transform and skip null values
When the lambda can return null, use mapNotNull to both transform and remove null values:
val coupons: List<String> = orders.mapNotNull { it.coupon }
println(coupons)
[SALE10, VIP]
The return type is List<String>, not List<String?>. This approach is cleaner and more type-safe than map { it.coupon }.filterNotNull().
3.3. mapIndexed: When you need the index
val lines = orders.mapIndexed { index, order -> "${index + 1}. Order #${order.id} - ${order.total}k" }
lines.forEach(::println)
1. Order #1 - 120k
2. Order #2 - 80k
3. Order #3 - 50k
4. Order #4 - 200k
5. Order #5 - 45k
3.4. map on a Map
With a Map, calling map returns a List. If you want the result to remain a Map, use mapValues or mapKeys:
val prices = mapOf("coffee" to 30, "tea" to 25)
val labels = prices.map { (name, price) -> "$name: ${price}k" } // List<String>
val withVat = prices.mapValues { (_, price) -> price * 110 / 100 } // Map<String, Int>
println(labels)
println(withVat)
[coffee: 30k, tea: 25k]
{coffee=33, tea=27}
4. filter: Keep elements that satisfy a condition
4.1. filter and filterNot
filter takes a predicate (T) -> Boolean and keeps elements where the predicate returns true:
val paid = orders.filter { it.status == Status.PAID }
val notCancelled = orders.filterNot { it.status == Status.CANCELLED }
println(paid.map { it.id })
println(notCancelled.map { it.id })
[1, 4, 5]
[2, 4, 5]
Wait, the second line is wrong. Order 1 wasn’t cancelled, so it should be included. The correct result is:
[1, 4, 5]
[1, 2, 4, 5]
filterNot is just a more readable way to write filter { !(...) }. Use it when a negated condition makes the code hard to read.
4.2. partition: Split into two in a single pass
If you need both the group that satisfies and the group that doesn’t, don’t call filter twice. partition returns a Pair containing two lists:
val (bigOrders, smallOrders) = orders.partition { it.total >= 100 }
println(bigOrders.map { it.id })
println(smallOrders.map { it.id })
[1, 4]
[2, 3, 5]
Destructuring with val (a, b) gives clear names to each half.
4.3. filterIsInstance: Filter by type
When working with heterogeneous lists, filterIsInstance<T>() both filters and smart-casts:
sealed interface Event
data class OrderPlaced(val orderId: Int) : Event
data class OrderShipped(val orderId: Int, val carrier: String) : Event
val events: List<Event> = listOf(OrderPlaced(1), OrderShipped(1, "GHN"), OrderPlaced(2))
val shipped: List<OrderShipped> = events.filterIsInstance<OrderShipped>()
println(shipped.map { it.carrier })
[GHN]
No need to manually cast with as. The result already has the type List<OrderShipped>.
5. groupBy: Group elements by a key
5.1. Basic groupBy
groupBy takes a key selector function and returns a Map<K, List<T>>:
val byCustomer: Map<String, List<Order>> = orders.groupBy { it.customer }
println(byCustomer.mapValues { (_, list) -> list.map { it.id } })
{An=[1, 3], Bình=[2, 5], Chi=[4]}
The returned Map is a LinkedHashMap. Keys are ordered by their first appearance in the original list, and the order of elements within each group is preserved.
5.2. groupBy with valueTransform
The second overload lets you transform values while grouping, so you don’t need a separate mapValues:
val totalsByCustomer: Map<String, List<Int>> =
orders.groupBy(keySelector = { it.customer }, valueTransform = { it.total })
println(totalsByCustomer)
{An=[120, 50], Bình=[80, 45], Chi=[200]}
5.3. Aggregating after grouping
The most common pattern is to group and then calculate a value for each group:
val revenueByStatus: Map<Status, Int> = orders
.groupBy { it.status }
.mapValues { (_, list) -> list.sumOf { it.total } }
println(revenueByStatus)
{PAID=365, NEW=80, CANCELLED=50}
5.4. groupingBy: Aggregate without intermediate lists
groupBy always creates a List for each group. If you only need to count or accumulate, groupingBy is more efficient because it accumulates directly:
val orderCount: Map<String, Int> = orders.groupingBy { it.customer }.eachCount()
val spentByCustomer: Map<String, Int> = orders
.groupingBy { it.customer }
.fold(0) { acc, order -> acc + order.total }
println(orderCount)
println(spentByCustomer)
{An=2, Bình=2, Chi=1}
{An=170, Bình=125, Chi=200}
groupingBy returns a Grouping object that hasn’t computed anything yet. Aggregation only runs when you call eachCount, fold, reduce, or aggregate.
5.5. How groupBy differs from associateBy
These two functions are often confused:
val lastOrderByCustomer: Map<String, Order> = orders.associateBy { it.customer }
println(lastOrderByCustomer.mapValues { it.value.id })
{An=3, Bình=5, Chi=4}
associateBy creates a Map<K, T>, meaning each key maps to one element. When keys collide, the later element overwrites the earlier one. So only use associateBy when the key is unique, like associateBy { it.id }. If keys can collide, use groupBy.
6. Chaining into a pipeline
The functions above can be chained together. The example below calculates revenue from paid orders by customer and ranks them in descending order:
fun paidRevenueRanking(orders: List<Order>): List<Pair<String, Int>> =
orders
.filter { it.status == Status.PAID }
.groupBy { it.customer }
.mapValues { (_, list) -> list.sumOf { it.total } }
.toList()
.sortedByDescending { it.second }
println(paidRevenueRanking(orders))
[(Chi, 200), (An, 120), (Bình, 45)]
The pipeline runs through these steps:
filterkeeps orders 1, 4, and 5.groupBy+mapValuesproduces{An=120, Chi=200, Bình=45}.toList()converts theMaptoList<Pair<String, Int>>so we can sort it.sortedByDescendingranks by revenue.
Note: Each operation on a
Listis eager. It traverses all data and creates a new list. With a few thousand elements, this is negligible. With very large data, or when you only need a few elements (take,first), switch toasSequence()for lazy evaluation:
val firstTwoBigPaid = orders.asSequence()
.filter { it.status == Status.PAID }
.map { it.id }
.take(2)
.toList()
println(firstTwoBigPaid)
[1, 4]
With Sequence, the pipeline stops as soon as it gets 2 elements. Order 5 is never examined.
7. Writing tests for the pipeline
Add JUnit 5 and AssertJ to pom.xml. Your Kotlin project should already have kotlin-maven-plugin:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class OrderStatsTest {
@Test
fun `rank revenue, only count paid orders`() {
val result = paidRevenueRanking(orders)
assertThat(result).containsExactly("Chi" to 200, "An" to 120, "Bình" to 45)
}
@Test
fun `groupingBy eachCount counts orders per customer correctly`() {
val count = orders.groupingBy { it.customer }.eachCount()
assertThat(count).containsExactlyEntriesOf(mapOf("An" to 2, "Bình" to 2, "Chi" to 1))
}
@Test
fun `filter does not modify original list`() {
orders.filter { it.total > 100 }
assertThat(orders).hasSize(5)
}
}
containsExactly checks both content and order. This is important because ranking results depend on order. containsExactlyEntriesOf also compares entry order, so it confirms that LinkedHashMap preserves order.
8. Common mistakes
- Using
filtertwice with opposite conditions. This traverses the list twice and is error-prone. Usepartitioninstead. - Using
associateByfor keys that can collide. Data gets overwritten silently, with no error. This is a hard-to-spot bug. - Using
groupByjust for counting or summing. This creates unnecessary intermediate lists. UsegroupingBy { }.eachCount()or.fold()instead—it’s cleaner and faster. - Causing side effects inside
map. For example, callingpaymentService.charge(it)insidemap. Usemapto transform data only. If you need to perform an action on each element, useforEachto make the intent clear. - Using
map { }.filterNotNull(). UsemapNotNullinstead. - Using
Sequencefor everything. With small lists,Sequenceis often slower due to iterator and lambda overhead. Switch toSequenceonly for large data, long pipelines, or early-stopping operations. - Calling
groupBymultiple times on the same data in a loop. Calculate it once, store theMapin a variable, then look it up.
Note: When data comes from a database, filtering and grouping should be done with SQL (
WHERE,GROUP BY) instead of loading everything into memory and callingfilter/groupBy. Collection functions are best for data already in memory and of reasonable size.
9. Conclusion
map transforms each element, filter keeps elements matching a condition, and groupBy groups elements into a Map by key. Each function has specialized variants:
mapNotNull,mapIndexed,mapValuesfor transformations.filterNot,partition,filterIsInstancefor filtering.groupingBy,associateByfor grouping and creatingMaps.
Choosing the right variant keeps code concise and bug-free. When chaining into a pipeline, write tests with AssertJ to verify both content and order. Consider Sequence for large data or early-stopping operations.