How do I use sequences to optimize performance with large Kotlin collections?

In Kotlin, sequences let you process large collections lazily, which can reduce temporary allocations and improve performance for chained operations.

The problem with regular collections

Collection operations like map, filter, and flatMap are usually eager:

val result = users
    .filter { it.isActive }
    .map { it.email }
    .take(10)

With a List, Kotlin typically creates intermediate collections:

  1. one list after filter
  2. another list after map
  3. then takes 10 items

For small collections, this is fine. For large collections, it can waste memory and CPU.

Use asSequence()

Convert the collection to a sequence:

val result = users
    .asSequence()
    .filter { it.isActive }
    .map { it.email }
    .take(10)
    .toList()

Now each element flows through the pipeline one at a time:

user -> filter -> map -> maybe included in result

Also, because of take(10), processing can stop as soon as 10 matching items are found.

Example

data class User(
    val name: String,
    val email: String,
    val isActive: Boolean
)

val emails = users
    .asSequence()
    .filter { it.isActive }
    .map { it.email.lowercase() }
    .distinct()
    .take(100)
    .toList()

This avoids building large intermediate lists before getting the final 100 emails.

When sequences help

Sequences are usually useful when you have:

  • large collections
  • multiple chained operations
  • short-circuiting operations, such as:
    • take
    • first
    • firstOrNull
    • any
    • all
    • none
    • find

Example:

val firstAdminEmail = users
    .asSequence()
    .filter { it.isActive }
    .filter { it.role == "admin" }
    .map { it.email }
    .firstOrNull()

This stops as soon as the first matching user is found.

When sequences may not help

Sequences are not always faster. They can be slower for:

  • small collections
  • simple one-step operations
  • cases where you need the entire result anyway
  • performance-critical tight loops where sequence overhead matters

For example, this may not benefit much:

val names = users.map { it.name }

A plain collection operation is often fine here.

Common pattern

Use this pattern:

val result = largeList
    .asSequence()
    .filter { condition(it) }
    .map { transform(it) }
    .take(50)
    .toList()

The important parts are:

  • asSequence() starts lazy processing
  • intermediate operations stay lazy
  • terminal operations execute the pipeline

Terminal operations include:

toList()
count()
first()
firstOrNull()
sumOf { ... }
any { ... }
forEach { ... }

Rule of thumb

Use sequences when your pipeline is large, chained, and potentially short-circuited.

For small or simple transformations, prefer normal collection operations for readability and often better performance.

How do I use immutability and copy-on-write with Kotlin collections?

In Kotlin, read-only collection interfaces plus copy-on-write updates are a common way to model immutable-style data.

Kotlin has two main collection interface families:

Mutable Read-only
MutableList<T> List<T>
MutableSet<T> Set<T>
MutableMap<K, V> Map<K, V>

Read-only interfaces prevent mutation through that reference, while copy-on-write means you create a new collection when you need a changed version.

Use read-only collection types

Prefer exposing List, Set, and Map instead of mutable versions:

class UserGroup(
    val users: List<String>
)

val group = UserGroup(listOf("Alice", "Bob"))

// group.users.add("Charlie") // Does not compile

This prevents callers from mutating the collection directly.

Copy-on-write with lists

To “modify” a list, create a new one:

val users = listOf("Alice", "Bob")

val withCharlie = users + "Charlie"
val withoutAlice = users - "Alice"

println(users)        // [Alice, Bob]
println(withCharlie)  // [Alice, Bob, Charlie]
println(withoutAlice) // [Bob]

The original list remains unchanged.

Copy-on-write with sets

val roles = setOf("USER", "ADMIN")

val updatedRoles = roles + "AUDITOR"
val reducedRoles = roles - "ADMIN"

println(roles)        // [USER, ADMIN]
println(updatedRoles) // [USER, ADMIN, AUDITOR]
println(reducedRoles) // [USER]

Copy-on-write with maps

val settings = mapOf(
    "theme" to "dark",
    "language" to "en"
)

val updatedSettings = settings + ("theme" to "light")
val removedSetting = settings - "language"

println(settings)
// {theme=dark, language=en}

println(updatedSettings)
// {theme=light, language=en}

println(removedSetting)
// {theme=dark}

Updating objects with immutable collections

This pattern is especially useful with data class and copy():

data class Team(
    val name: String,
    val members: List<String>
)

val team = Team(
    name = "Backend",
    members = listOf("Alice", "Bob")
)

val updatedTeam = team.copy(
    members = team.members + "Charlie"
)

println(team)
// Team(name=Backend, members=[Alice, Bob])

println(updatedTeam)
// Team(name=Backend, members=[Alice, Bob, Charlie])

Avoid leaking mutable collections

Be careful with this:

val mutableUsers = mutableListOf("Alice", "Bob")
val users: List<String> = mutableUsers

mutableUsers += "Charlie"

println(users) // [Alice, Bob, Charlie]

Even though users is typed as List<String>, the underlying collection is still mutable.

If you need a defensive copy:

val users: List<String> = mutableUsers.toList()

Encapsulate mutation internally

A common pattern is to keep a mutable collection private, but expose a read-only copy:

class UserRegistry {
    private val users = mutableListOf<String>()

    fun addUser(user: String) {
        users += user
    }

    fun users(): List<String> = users.toList()
}

This prevents external code from modifying your internal list.

Prefer immutable state updates

For state management, prefer this style:

data class AppState(
    val selectedIds: Set<Int>,
    val usersById: Map<Int, String>
)

val state = AppState(
    selectedIds = setOf(1, 2),
    usersById = mapOf(
        1 to "Alice",
        2 to "Bob"
    )
)

val newState = state.copy(
    selectedIds = state.selectedIds + 3,
    usersById = state.usersById + (3 to "Charlie")
)

Use persistent collections for heavy updates

Kotlin’s standard collections are read-only interfaces, but not deeply immutable persistent data structures.

If you frequently create modified copies of large collections, consider kotlinx.collections.immutable:

import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf

val users: PersistentList<String> = persistentListOf("Alice", "Bob")

val updatedUsers = users.add("Charlie")

println(users)        // [Alice, Bob]
println(updatedUsers) // [Alice, Bob, Charlie]

Persistent collections share internal structure, so copy-on-write-style updates are usually more efficient than copying a whole List.

Summary

Use:

val names: List<String> = listOf("Alice", "Bob")
val updated = names + "Charlie"

Instead of:

val names = mutableListOf("Alice", "Bob")
names += "Charlie"

General guidance:

  • Expose List, Set, and Map, not mutable types.
  • Use +, -, map, filter, and copy() to create updated values.
  • Avoid leaking mutable backing collections.
  • Use toList(), toSet(), or toMap() for defensive copies.
  • Use kotlinx.collections.immutable for efficient persistent immutable collections.

How do I combine multiple collection operations in a single Kotlin chain?

In Kotlin, you can combine multiple collection operations by chaining functions like filter, map, sortedBy, take, groupBy, and others.

Each operation returns a new collection, so you can call the next operation directly on the result.

val numbers = listOf(1, 2, 3, 4, 5, 6)

val result = numbers
    .filter { it % 2 == 0 }
    .map { it * 10 }
    .sorted()

println(result) // [20, 40, 60]

Here’s what happens:

  1. filter { it % 2 == 0 } keeps only even numbers
  2. map { it * 10 } transforms each number
  3. sorted() sorts the result

You can also chain operations on objects:

data class User(
    val name: String,
    val age: Int,
    val active: Boolean
)

val users = listOf(
    User("Alice", 30, true),
    User("Bob", 17, true),
    User("Charlie", 25, false),
    User("Diana", 22, true)
)

val activeAdultNames = users
    .filter { it.active }
    .filter { it.age >= 18 }
    .map { it.name }
    .sorted()

println(activeAdultNames) // [Alice, Diana]

You can often combine related filters into one:

val activeAdultNames = users
    .filter { it.active && it.age >= 18 }
    .map { it.name }
    .sorted()

For maps, you can chain over entries:

val scores = mapOf(
    "Alice" to 90,
    "Bob" to 75,
    "Charlie" to 85
)

val passedNames = scores
    .filter { (_, score) -> score >= 80 }
    .map { (name, _) -> name }
    .sorted()

println(passedNames) // [Alice, Charlie]

If the collection is large or the chain is expensive, use asSequence() to make intermediate operations lazy:

val result = numbers
    .asSequence()
    .filter { it % 2 == 0 }
    .map { it * 10 }
    .sorted()
    .toList()

Use regular collection chains for simple cases, and asSequence() when you want to avoid creating intermediate collections during multi-step processing.

How do I use destructuring with maps and pairs in Kotlin?

In Kotlin, destructuring lets you unpack values from an object into separate variables.

It is commonly used with:

  • Pair
  • Map.Entry
  • loops over maps
  • lambda parameters

Destructuring a Pair

A Pair<A, B> contains two values: first and second.

val pair = "Alice" to 25

val (name, age) = pair

println(name) // Alice
println(age)  // 25

This is equivalent to:

val name = pair.first
val age = pair.second

You can also create a Pair explicitly:

val pair = Pair("Kotlin", 2.1)

val (language, version) = pair

println(language) // Kotlin
println(version)  // 2.1

Destructuring map entries in a loop

When you iterate over a map, each item is a Map.Entry<K, V>. Kotlin lets you destructure it into key and value:

val ages = mapOf(
    "Alice" to 25,
    "Bob" to 30
)

for ((name, age) in ages) {
    println("$name is $age years old")
}

Here:

(name, age)

means:

name = entry.key
age = entry.value

Destructuring in lambdas

You can destructure map entries inside lambda parameters too:

val scores = mapOf(
    "Alice" to 90,
    "Bob" to 85,
    "Charlie" to 95
)

scores.forEach { (name, score) ->
    println("$name scored $score")
}

You can also use it with operations like map, filter, and sortedBy:

val passingStudents = scores.filter { (_, score) ->
    score >= 90
}

println(passingStudents) // {Alice=90, Charlie=95}

Ignoring values with _

If you only need one part of the destructured value, use _ for the part you want to ignore.

val user = "Alice" to 25

val (name, _) = user

println(name) // Alice

With a map:

val ages = mapOf(
    "Alice" to 25,
    "Bob" to 30
)

for ((name, _) in ages) {
    println(name)
}

Or if you only need values:

for ((_, age) in ages) {
    println(age)
}

Destructuring mutable maps

Destructuring works the same way with mutable maps:

val inventory = mutableMapOf(
    "Apples" to 10,
    "Oranges" to 5
)

for ((item, count) in inventory) {
    println("$item: $count")
}

If you want to update values, use the key:

for ((item, count) in inventory) {
    inventory[item] = count + 1
}

println(inventory) // {Apples=11, Oranges=6}

Important detail: destructuring depends on componentN() functions

Destructuring works when a type provides functions like:

component1()
component2()

Pair has:

component1() // first
component2() // second

Map entries support destructuring as:

component1() // key
component2() // value

So this:

val (key, value) = "id" to 123

is essentially:

val pair = "id" to 123
val key = pair.component1()
val value = pair.component2()

Quick summary

val pair = "Alice" to 25
val (name, age) = pair

val map = mapOf(
    "Alice" to 25,
    "Bob" to 30
)

for ((key, value) in map) {
    println("$key -> $value")
}

map.forEach { (key, value) ->
    println("$key -> $value")
}

Use destructuring when it makes key-value or pair-based code easier to read.

How do I use associateBy, partition, and zip in Kotlin?

In Kotlin, associateBy, partition, and zip are collection operations that help transform or split collections.

associateBy

associateBy creates a Map from a collection by choosing a key for each element.

data class User(val id: Int, val name: String)

val users = listOf(
    User(1, "Alice"),
    User(2, "Bob"),
    User(3, "Charlie")
)

val usersById = users.associateBy { user -> user.id }

println(usersById)

Output:

{
  1=User(id=1, name=Alice),
  2=User(id=2, name=Bob),
  3=User(id=3, name=Charlie)
}

You can also choose both the key and the value:

val namesById = users.associateBy(
    keySelector = { it.id },
    valueTransform = { it.name }
)

println(namesById)

Output:

{1=Alice, 2=Bob, 3=Charlie}

If multiple elements produce the same key, the last one wins:

val words = listOf("one", "two", "three", "ten")

val byFirstLetter = words.associateBy { it.first() }

println(byFirstLetter)

Output:

{o=one, t=ten}

Here, "ten" replaces "two" and "three" for key 't'.


partition

partition splits a collection into two lists based on a predicate.

It returns a Pair<List<T>, List<T>>:

  • first contains elements where the predicate is true
  • second contains elements where the predicate is false
val numbers = listOf(1, 2, 3, 4, 5, 6)

val result = numbers.partition { it % 2 == 0 }

val evens = result.first
val odds = result.second

println(evens)
println(odds)

Output:

[2, 4, 6]
[1, 3, 5]

Commonly, you destructure the result:

val (evens, odds) = numbers.partition { it % 2 == 0 }

println(evens)
println(odds)

Example with objects:

data class Task(val title: String, val done: Boolean)

val tasks = listOf(
    Task("Write tests", true),
    Task("Fix bug", false),
    Task("Deploy", false)
)

val (completed, pending) = tasks.partition { it.done }

println(completed)
println(pending)

zip

zip combines two collections element by element.

val names = listOf("Alice", "Bob", "Charlie")
val ages = listOf(25, 30, 35)

val people = names.zip(ages)

println(people)

Output:

[(Alice, 25), (Bob, 30), (Charlie, 35)]

The result is a List<Pair<A, B>>.

You can destructure each pair:

for ((name, age) in names.zip(ages)) {
    println("$name is $age years old")
}

Output:

Alice is 25 years old
Bob is 30 years old
Charlie is 35 years old

You can also provide a transform function:

val descriptions = names.zip(ages) { name, age ->
    "$name is $age years old"
}

println(descriptions)

Output:

[Alice is 25 years old, Bob is 30 years old, Charlie is 35 years old]

If the collections have different sizes, zip stops at the shorter one:

val letters = listOf("A", "B", "C")
val numbers = listOf(1, 2)

println(letters.zip(numbers))

Output:

[(A, 1), (B, 2)]

Quick comparison

Function Purpose Result
associateBy Turns a collection into a Map Map<K, T> or Map<K, V>
partition Splits a collection into two lists Pair<List<T>, List<T>>
zip Combines two collections element by element List<Pair<A, B>> or List<R>

Example using all three:

data class Person(val id: Int, val name: String, val age: Int)

val people = listOf(
    Person(1, "Alice", 17),
    Person(2, "Bob", 21),
    Person(3, "Charlie", 30)
)

val peopleById = people.associateBy { it.id }

val (adults, minors) = people.partition { it.age >= 18 }

val namesWithAges = people.map { it.name }.zip(people.map { it.age })

println(peopleById)
println(adults)
println(minors)
println(namesWithAges)

Use:

  • associateBy when you want lookup by a key
  • partition when you want to split a list into matching and non-matching groups
  • zip when you want to pair two collections by position