Lists
A List in Kotlin is an ordered collection that holds elements you can access by their numeric index, starting at 0. Unlike a plain array, a Kotlin List is part of the standard collections framework and comes in two flavors: a read-only List<T> that only lets you read elements, and a MutableList<T> that also lets you add, remove, and change them. Lists are one of the most common data structures in everyday Kotlin code, from holding a handful of names to processing thousands of records with functional-style operations like map and filter.
Overview: How Lists Work in Kotlin
Kotlin draws a firm line between read-only and mutable collections, and this applies to lists just as it does to sets and maps. The interface kotlin.collections.List<T> exposes only reading operations: size, get(index), contains, iteration, and so on. It deliberately has no add, remove, or set methods. The interface kotlin.collections.MutableList<T> extends List<T> and adds those mutating operations. This is a compile-time distinction only — under the hood, both are usually backed by the same array-based implementation on the JVM (java.util.ArrayList). There is no separate immutable list class; List<T> is a read-only view of whatever backing collection it wraps, and that backing collection may still be mutable through another reference (more on this in Common Mistakes).
When you call listOf(...), Kotlin builds a list and returns it typed as List<T>, so the compiler will not let you call mutating methods on it — writing numbers.add(4) on a listOf result is a compile error, not a runtime one. Call mutableListOf(...) instead when you know you will need to change the contents later. This lines up with Kotlin’s broader val/var philosophy: prefer the least mutable option that gets the job done, and let the type system, not documentation, enforce that promise.
Lists are generic, and like every Kotlin type, their element type is non-null by default. List<String> is guaranteed to contain only non-null String values — the compiler will not let a null slip in. If you need a list that can hold null elements, declare it as List<String?>. That is different from a nullable reference to a list, written List<String>?, where the list itself might be null but every element inside it, if the list exists, is guaranteed non-null. Reading List<String?> versus List<String>? slowly until the difference clicks is worth it — the question mark’s position tells you exactly what can be null.
Internally, a list created with listOf or mutableListOf is backed by a resizable array. Reading an element by index (list[i]) is a constant-time operation, while inserting or removing near the front shifts every following element and is linear time. If your use case is dominated by insertions and removals in the middle of a large collection, a linked-list implementation may perform better, but for the vast majority of everyday Kotlin code the default array-backed list is exactly what you want.
Syntax
Lists are created with factory functions rather than a constructor call.
val readOnly: List<Int> = listOf(1, 2, 3)
val mutable: MutableList<Int> = mutableListOf(1, 2, 3)
val empty: List<String> = emptyList()
val fromArrayList: MutableList<Int> = ArrayList()
listOf(vararg elements)— creates a read-onlyList<T>from the given elements (useemptyList()for an empty one, since it needs no arguments).mutableListOf(vararg elements)— creates aMutableList<T>, backed byArrayList, that supports adding and removing elements.list[index]orlist.get(index)— reads the element at a zero-based index; throwsIndexOutOfBoundsExceptionif the index is out of range.list[index] = valueorlist.set(index, value)— replaces the element at an index (only available onMutableList).list.size— the number of elements, as anInt.
| Function | Purpose |
|---|---|
add(element), remove(element) |
Append or remove an element (mutable only) |
map { } |
Transform each element into a new list |
filter { } |
Keep only elements matching a predicate |
sortedBy { }, sortedByDescending { } |
Return a new sorted list |
groupBy { } |
Partition elements into a Map keyed by a selector |
getOrNull(i), firstOrNull { } |
Look up an element safely, returning null if absent |
sum(), average(), max()/min() |
Numeric aggregation over the list |
Examples
Example 1: Creating and reading a list
fun main() {
val fruits = listOf("Apple", "Banana", "Cherry")
println("Number of fruits: ${fruits.size}")
for (fruit in fruits) {
println(fruit)
}
println("First: ${fruits[0]}")
println("Contains Banana: ${fruits.contains("Banana")}")
}
Output:
Number of fruits: 3
Apple
Banana
Cherry
First: Apple
Contains Banana: true
This creates a read-only list of three strings with listOf. fruits.size reports the element count, the for loop walks every element in order using the list’s iterator, fruits[0] reads the first element via the index operator, and contains performs a search for a matching value using equals.
Example 2: Modifying a mutable list
fun main() {
val numbers = mutableListOf(1, 2, 3)
numbers.add(4)
numbers.add(0, 0)
numbers.remove(2)
println(numbers)
val doubled = numbers.map { it * 2 }
println(doubled)
val evens = numbers.filter { it % 2 == 0 }
println(evens)
}
Output:
[0, 1, 3, 4]
[0, 2, 6, 8]
[0, 4]
Starting from [1, 2, 3], add(4) appends to the end, giving [1, 2, 3, 4], and add(0, 0) inserts the value 0 at index 0, giving [0, 1, 2, 3, 4]. The call numbers.remove(2) is easy to misread — it removes the value 2, not the element at index 2, leaving [0, 1, 3, 4]. Note that val numbers is perfectly legal here even though the contents change: val only prevents reassigning the numbers variable itself to a different list, not mutating the list object it points to. map and filter each produce an independent new list without touching numbers.
Example 3: Sorting and grouping a list of data class objects
data class Student(val name: String, val grade: Int)
fun main() {
val students = listOf(
Student("Alice", 90),
Student("Bob", 75),
Student("Charlie", 90),
Student("Diana", 60)
)
val sorted = students.sortedByDescending { it.grade }
for (s in sorted) {
println("${s.name}: ${s.grade}")
}
val grouped = students.groupBy { it.grade >= 80 }
println("Passed: ${grouped[true]?.size ?: 0}")
println("Failed: ${grouped[false]?.size ?: 0}")
val average = students.map { it.grade }.average()
println("Average grade: $average")
}
Output:
Alice: 90
Charlie: 90
Bob: 75
Diana: 60
Passed: 2
Failed: 2
Average grade: 78.75
Student is a data class, so it automatically gets a readable toString, structural equals/hashCode, and a copy function, even though none are used directly here. sortedByDescending returns a new list ordered from the highest grade to the lowest; Kotlin’s sort is stable, so Alice and Charlie — tied at 90 — keep their original relative order. groupBy partitions the list into a Map<Boolean, List<Student>> keyed by whether each student’s grade is at least 80; because a key might have no matching students, grouped[true] returns a nullable List<Student>?, so ?.size ?: 0 safely falls back to zero instead of risking a null pointer. Finally, map { it.grade } extracts just the grades into a List<Int>, and average() computes their mean as a Double.
How It Works Step by Step
When you write for (fruit in fruits), the compiler desugars this into a call to fruits.iterator(), followed by repeated calls to hasNext() and next() until the iterator is exhausted. When you write fruits[0], that square-bracket syntax is operator syntax for fruits.get(0); Kotlin lets any class that defines an appropriately named get operator function use index-bracket syntax, and List defines exactly that. Assigning through brackets, list[0] = "X", similarly desugars to list.set(0, "X") and only compiles for a MutableList, because List never declares a set operator in the first place.
Functional operations like map and filter are not special language syntax — they are ordinary extension functions defined on Iterable<T> in the standard library. map walks the list once, applies your lambda to each element, and collects the results into a brand-new List; it never modifies the original. This return-a-new-list behavior is consistent across nearly the entire collections API — filter, sorted, plus, and friends all leave the receiver untouched and hand back a fresh collection, which is why chaining several of them together is both safe and idiomatic.
Common Mistakes
Mistake 1: Indexing past the end of the list
Reading an index that does not exist throws IndexOutOfBoundsException at runtime — Kotlin’s null safety does not protect you here, because the list itself is not null, the index is simply invalid.
val numbers = listOf(1, 2, 3)
println(numbers[5]) // IndexOutOfBoundsException: no such index
Use getOrNull(index), which returns null instead of throwing, and handle the absence explicitly with the Elvis operator:
fun main() {
val numbers = listOf(1, 2, 3)
val value = numbers.getOrNull(5) ?: -1
println(value)
}
Mistake 2: Assuming a read-only List reference means the data can never change
List<T> only guarantees that you cannot mutate it through that particular reference — it does not guarantee the underlying collection is frozen. If a MutableList is exposed elsewhere as a List, changes made through the mutable reference are still visible through the read-only one, because both point at the same object in memory.
fun main() {
val mutable = mutableListOf(1, 2, 3)
val readOnlyView: List<Int> = mutable
mutable.add(4)
println(readOnlyView) // [1, 2, 3, 4] -- the read-only view still moved
}
If you need a true snapshot that cannot be affected by later changes elsewhere, copy the elements into a new list with toList():
fun main() {
val mutable = mutableListOf(1, 2, 3)
val snapshot: List<Int> = mutable.toList()
mutable.add(4)
println(snapshot)
}
Mistake 3: Removing elements from a list while iterating over it
Modifying a MutableList‘s contents with a regular for loop while it is being iterated invalidates the iterator and throws ConcurrentModificationException.
fun main() {
val numbers = mutableListOf(1, 2, 3, 4, 5)
for (n in numbers) {
if (n % 2 == 0) {
numbers.remove(n) // ConcurrentModificationException
}
}
}
Use a dedicated removal function such as removeAll, which is written to handle this safely, instead of mutating a list you are actively looping over:
fun main() {
val numbers = mutableListOf(1, 2, 3, 4, 5)
numbers.removeAll { it % 2 == 0 }
println(numbers)
}
Best Practices
- Default to
listOfand the read-onlyList<T>type; only reach formutableListOfwhen the collection genuinely needs to grow or shrink after creation. - Prefer chaining
map/filter/sortedByover hand-written loops with a mutable accumulator — it is shorter, avoids off-by-one bugs, and states your intent directly. - Use
getOrNull,firstOrNull, orlastOrNullinstead of index access orfirst()/last()when the element might not exist, and handle thenullwith?:or?.letrather than!!. - Return
List<T>, notMutableList<T>, from public functions unless callers genuinely need to mutate the result — this keeps your API’s contract honest. - Remember that
valon a list only locks the reference, not the contents; if you need a real immutability guarantee, copy a mutable collection withtoList()before sharing it. - Use destructuring (
val (first, second) = list) only on lists you know have enough elements — Kotlin supports destructuring up to the first five elements of aList, and it throws if the list is shorter.
Practice Exercises
- Given
val scores = listOf(88, 92, 79, 95, 60), write an expression that returns a new list containing only the scores of 80 or above, sorted from highest to lowest. Expected result:[95, 92, 88]. - Create a
MutableList<String>of at least four names. Remove the second element using its index, add a new name to the end, then print the final list. - Write a function
fun secondOrNull(list: List<Int>): Int?that returns the second element of a list, ornullif the list has fewer than two elements, without using!!. Test it on both a short and a long list.
Summary
List<T>is read-only;MutableList<T>addsadd,remove, andset— both are usually backed by the same array-based implementation.- Create lists with
listOf(...)ormutableListOf(...); index withlist[i], which desugars toget/setoperator calls. - List element types are non-null by default — write
List<String?>for a list of nullable elements, andList<String>?for a nullable reference to a list of non-null elements. - Operations like
map,filter, andsortedByreturn a brand-new list and never mutate the receiver. - A read-only
Listreference does not guarantee the underlying data is frozen — copy withtoList()for a true snapshot. - Never mutate a
MutableListwhile iterating it directly; useremoveAllor build a new filtered list instead.
