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:

  • filter keeps orders 1, 4, and 5.
  • groupBy + mapValues produces {An=120, Chi=200, Bình=45}.
  • toList() converts the Map to List<Pair<String, Int>> so we can sort it.
  • sortedByDescending ranks by revenue.

Note: Each operation on a List is 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 to asSequence() 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 filter twice with opposite conditions. This traverses the list twice and is error-prone. Use partition instead.
  • Using associateBy for keys that can collide. Data gets overwritten silently, with no error. This is a hard-to-spot bug.
  • Using groupBy just for counting or summing. This creates unnecessary intermediate lists. Use groupingBy { }.eachCount() or .fold() instead—it’s cleaner and faster.
  • Causing side effects inside map. For example, calling paymentService.charge(it) inside map. Use map to transform data only. If you need to perform an action on each element, use forEach to make the intent clear.
  • Using map { }.filterNotNull(). Use mapNotNull instead.
  • Using Sequence for everything. With small lists, Sequence is often slower due to iterator and lambda overhead. Switch to Sequence only for large data, long pipelines, or early-stopping operations.
  • Calling groupBy multiple times on the same data in a loop. Calculate it once, store the Map in 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 calling filter/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, mapValues for transformations.
  • filterNot, partition, filterIsInstance for filtering.
  • groupingBy, associateBy for grouping and creating Maps.

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.

kotlincollectionmapfiltergroupBy