Most Android engineers reach for listOf() without thinking, then wonder why they can't add elements later. Understanding Kotlin collections isn't about memorizing API docs—it's about knowing which tool fits each job and how to chain operations without unnecessary allocations. This practical guide covers lists, sets and maps with powerful operators that ship in production code every day.
listOf vs mutableListOf
The distinction between immutable and mutable collections shapes how we write reliable Android code. listOf() returns a read-only List, while mutableListOf() returns a MutableList that supports add, remove, and set operations.
In a typical app lifecycle, consider a screen that displays a list of users fetched from an API. The data arrives as a network response, gets transformed, and passes to the UI layer.
// Network layer - immutable by default
data class UserResponse(val users: List)
// ViewModel - holds the list reference
class UserListViewModel {
private val _users = mutableListOf()
val users: List = _users // Expose read-only reference
fun loadUsers() {
// _users is mutable inside the ViewModel
// But external consumers see only List
}
}
We expose List from the ViewModel, not MutableList. This prevents the UI layer from accidentally mutating data that the ViewModel manages. The UI observes changes through proper channels like StateFlow, not by calling users.add().
Start with immutable collections everywhere. Only introduce mutableListOf() when you own the mutation logic end-to-end. In Android, that's usually just a few strategic places: ViewModels, repository caches, and temporary working collections.
map, filter, groupBy and associateBy
These four operators handle the majority of real-world collection transformations. They're not optional sugar—they're fundamental to clean, expressive code.
map and filter
Transform every element, or keep only elements matching a condition.
val users = listOf(
User("alice", 28),
User("bob", 35),
User("charlie", 22)
)
// Transform: extract names
val names = users.map { it.name } // [alice, bob, charlie]
// Filter: keep users older than 25
val adults = users.filter { it.age > 25 } // [alice, bob]
// Chain them
val adultNames = users
.filter { it.age > 25 }
.map { it.name } // [alice, bob]
In a real Android app, you'd chain these to prepare data for a RecyclerView adapter.
groupBy and associateBy
groupBy partitions a collection by a key, returning a Map>. Each key maps to all elements that share that key. Use this when you need to group related items—invoices by date, messages by sender, products by category.
data class Order(val id: Int, val status: String, val amount: Double)
val orders = listOf(
Order(1, "shipped", 99.99),
Order(2, "pending", 49.99),
Order(3, "shipped", 199.99),
Order(4, "pending", 29.99)
)
// Group orders by status
val ordersByStatus = orders.groupBy { it.status }
// {
// "shipped" -> [Order(1), Order(3)],
// "pending" -> [Order(2), Order(4)]
// }
// Calculate total per status
val totalByStatus = orders
.groupBy { it.status }
.mapValues { (_, orderList) -> orderList.sumOf { it.amount } }
// { "shipped" -> 299.98, "pending" -> 79.98 }
associateBy creates a map where each element becomes a value, and you provide the key logic. It returns Map, so each key must be unique. Use this when you want to index a list by a unique identifier—users by ID, products by SKU, cache entries by URL.
val users = listOf(
User(1, "alice"),
User(2, "bob"),
User(3, "charlie")
)
// Index users by ID for O(1) lookup
val userById = users.associateBy { it.id }
// { 1 -> User(1, "alice"), 2 -> User(2, "bob"), ... }
val user = userById[2] // O(1) instead of O(n) search
If two elements produce the same key with associateBy, the later one overwrites the earlier one. For true one-to-many relationships, use groupBy.
Sequences for large lazy pipelines
When you chain multiple transformations on a large collection, intermediate lists pile up in memory. A List.filter { }.map { }.filter { } chain creates two temporary lists. Sequences defer computation until you call a terminal operation, processing each element through the full pipeline before moving to the next.
val numbers = (1..10_000_000).toList()
// Eager - creates intermediate lists
val result1 = numbers
.filter { it % 2 == 0 } // 5 million items
.map { it * 2 } // 5 million items
.take(10) // Finally: stop and return 10
// Lazy - stops after 10 elements
val result2 = numbers
.asSequence()
.filter { it % 2 == 0 }
.map { it * 2 }
.take(10)
.toList() // Convert back to List
The sequence version stops filtering and mapping once it has 10 results. The eager version filters and maps all 5 million even numbers before take() stops it.
Use sequences when:
- Pipelines are long (3+ chained operations).
- Collections are large (thousands or more elements).
- You use terminal operations that stop early, like
take(),first(), orany(). - You're filtering aggressively and expect most elements to be dropped.
In Android, a typical use case is processing lists of database rows or API responses before display.
repository.getAllOrders()
.asSequence()
.filter { it.createdAt > cutoffDate }
.filter { it.status == "completed" }
.map { it.toUiModel() }
.take(50)
.toList()
Don't prematurely optimize with sequences for small collections (< 100 items) or short pipelines. The overhead of sequence objects outweighs the benefit. Measure real performance issues in your profiler first.
Prefer read-only collections
This principle runs through production Kotlin codebases. By default, expose List, Set, and Map from your functions and properties. Only return mutable variants when callers genuinely own the mutation lifecycle.
Read-only collections enable refactoring, prevent bugs from unexpected mutations, and make code intent clear. A function returning List signals "you shouldn't modify this." A function returning MutableList signals "modify as you see fit," which spreads responsibility.
// Bad - callers might mutate shared state
class UserRepository {
fun getUsers(): MutableList {
return database.fetchUsers()
}
}
// Good - callers get a snapshot
class UserRepository {
fun getUsers(): List {
return database.fetchUsers()
}
}
// If callers need to mutate, they make their own list
val myUsers = userRepository.getUsers().toMutableList()
myUsers.add(newUser)
This pattern is especially critical in multi-threaded environments like Android's main thread plus background coroutines. Immutable data structures and read-only collection types prevent race conditions that are subtle and hard to debug.
Common mistakes
Mutating while iterating: Don't modify a collection while looping over it. Create a copy first.
// Bad
val list = mutableListOf(1, 2, 3, 4)
for (item in list) {
if (item == 2) list.remove(item) // ConcurrentModificationException
}
// Good
val list = mutableListOf(1, 2, 3, 4)
list.removeAll { it == 2 }
Overusing sequences on small data: Sequence objects have overhead. For collections under 100 items or short pipelines, stick with eager lists.
Exposing mutable internals: Never return a mutable collection reference from your public API unless mutation is the entire point.
// Bad
class Cache {
private val data = mutableMapOf()
fun getData(): MutableMap = data
}
// Good
class Cache {
private val data = mutableMapOf()
fun getData(): Map = data
}
Confusing groupBy with toMap: groupBy returns Map>; toMap expects pairs and returns Map. Pick the right tool.
Key takeaways
- Use immutable collections (
listOf,setOf,mapOf) by default. Mutable variants are the exception, not the rule. map,filter,groupBy, andassociateByhandle most real-world transformations. Chain them cleanly without intermediate variables.- Reach for sequences when processing large collections through long pipelines, especially with early-terminating operations like
take()orfirst(). - Expose read-only collection types from your functions and ViewModels. Callers make their own mutable copies if they need to modify.
- Prefer structural collection operations over manual loops. They're clearer, less error-prone, and easier to parallelize later if needed.






Comments
Loading comments…