Working with Collections in Kotlin: Best Practices Inspired by Effective Kotlin

Introduction

Collections are essential for managing groups of data in Kotlin. This guide introduces Kotlin collections, provides practical examples of common operations, and outlines best practices inspired by Effective Kotlin by Marcin Moskala to ensure your code is efficient and maintainable.

We encounter collections in almost every Kotlin application, so mastering them is crucial for writing clean and efficient code, and understanding how to work with collections effectively can significantly enhance your Kotlin programming skills.

Rules of thumb

1. Choose the right collection type

Select the appropriate collection based on your use case: List for ordered elements, Set for unique items, and Map for key-value pairs. Each collection type has its strengths and weaknesses, so understanding their characteristics will help you make informed decisions.

Understanding Kotlin's collection types is crucial:

Collection TypeCharacteristicsIdeal Use Cases
ListOrdered, allows duplicatesIndexed access, preserving insertion order
SetUnordered, unique elements onlyChecking existence, removing duplicates
MapKey-value pairsQuick lookups based on unique keys

2. Prefer immutability (but not always!)

Always use immutable collections by default. Mutable collections should be internal and encapsulated. This reduces the risk of unintended modifications and makes your code more predictable. If you find yourself needing to modify a collection, consider whether you can achieve the same result with a new immutable collection.

Prefer immutable collections (listOf, setOf, mapOf) to enhance predictability. Defaulting to mutable collections can feel convenient but can lead to unintended side effects. Examples of unfortunate side effects include accidental modifications leading to bugs that are hard to track down. Immutable collections are thread-safe and can be shared without concern for concurrent modifications.

For the following examples, we will use listOf for immutable collections and mutableListOf for mutable collections, but the same principles apply to other collection types like setOf, mapOf, and their mutable counterparts.

Defining an immutable collection is straightforward:

val immutableList = listOf(1, 2, 3)
immutableList.add(4) // ❌ Error: Unresolved reference 'add'.

These lists are read-only, meaning you cannot add or remove elements after creation. This immutability helps prevent bugs and makes your code easier to reason about.

Similarly a mutable collection can be defined as follows:

private val mutableList = mutableListOf(1, 2, 3)
mutableList.add(4) // ✅ Works fine, mutableList is now [1, 2, 3, 4]

When should you use mutable collections?

The biggest advantage of mutable collections is performance. If you need to frequently modify a collection, using a mutable collection can be more efficient than creating new immutable collections each time.

However, you should still encapsulate mutable collections to prevent unintended modifications from outside your class or function.

private val _mutableList = mutableListOf(1, 2, 3)
val immutableList: List<Int> get() = _mutableList
fun addElement(element: Int) {
  _mutableList.add(element) // Only this function can modify the collection
}

3. Avoid unnecessary intermediate collections

Chaining operations like map and filter creates intermediate collections. Minimize these by using sequences. Sequences allow you to process collections lazily, which can significantly improve performance, especially with large datasets. This means that operations are performed one element at a time, rather than creating intermediate collections for each operation.

Let's say you want to filter and map a list of numbers:

val numbers = listOf(1, 2, 3, 4, 5)
val result = numbers.filter { it % 2 == 0 } // Intermediate collections created here
  .map { it * 2 }                           // Intermediate collections created here
  .toList()                                 // Final collection created here
println(result) // [4, 8]

This creates two intermediate collections: one for the filtered numbers and another for the mapped results. Instead, you can use a sequence to avoid these intermediate collections:

val numbers = listOf(1, 2, 3, 4, 5)
val result = numbers.asSequence()
  .filter { it % 2 == 0 }                   // Process elements lazily
  .map { it * 2 }                           // Process elements lazily
  .toList()                                 // Final collection created here
println(result) // [4, 8]

This approach processes each element only once, improving performance and reducing memory overhead.

4. Use Sequences for performance with large datasets

Using sequences is not always the best choice, especially for small collections, but it shines with larger datasets or complex operations. Sequences process operations lazily (one element at a time), improving performance for large collections.

5. Understand collection processing order

Collections execute operations eagerly (one operation fully before the next), while sequences process lazily (one element at a time). This means that with collections, all elements are processed before moving to the next operation, while sequences allow for more efficient processing by only evaluating elements as needed.

Generally I tend to reccommend this order of operations for collections: filtermapsorted. This order ensures that you filter out unnecessary elements before mapping and sorting, which can lead to better performance (duh).

6. Prefer built-in collection functions

Leverage Kotlin’s powerful built-in functions for readability and efficiency. Below is a table containing some of the collection functions you should be familiar with:

Category / FamilyKey Functions (comma‑separated)Typical Use
TransformationmapmapNotNullflatMapflattenassociateByConvert or reshape elements / structure
FilteringfilterfilterNotNulldistinctdistinctByKeep, drop, or deduplicate elements
Grouping & AggregationgroupBygroupingByfoldreduceBucket data or fold into a single value
Combining & SplittingzippartitionchunkedwindowedMerge collections or slice one into parts
Searching & RetrievalfindfirstOrNullcontainsLocate specific elements quickly

For usage, refer to the the official Kotlin documentation.

7. Consider associating elements to a map

When you need to associate elements with keys, use associateBy or associate. This allows you to create a map from a collection, making lookups efficient.

val people = listOf(Person("Alice", 30), Person("Bob", 25))
val peopleByName = people.associateBy { it.name } // Creates a Map<String, Person>
println(peopleByName["Alice"]) // Person(name=Alice, age=30)

This is in contrast to using a list and searching through it, which would be less efficient:

val people = listOf(Person("Alice", 30), Person("Bob", 25))
val alice = people.find { it.name == "Alice" } // O(n) search
println(alice) // Person(name=Alice, age=30)

Conclusion

In this post, I've skimmed through some of the best practices for working with collections in Kotlin, inspired by Effective Kotlin. By choosing the right collection type, preferring immutability, avoiding unnecessary intermediate collections, and leveraging sequences, you can write more efficient and maintainable code.