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-only List<T> from the given elements (use emptyList() for an empty one, since it needs no arguments).
  • mutableListOf(vararg elements) — creates a MutableList<T>, backed by ArrayList, that supports adding and removing elements.
  • list[index] or list.get(index) — reads the element at a zero-based index; throws IndexOutOfBoundsException if the index is out of range.
  • list[index] = value or list.set(index, value) — replaces the element at an index (only available on MutableList).
  • list.size — the number of elements, as an Int.
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 listOf and the read-only List<T> type; only reach for mutableListOf when the collection genuinely needs to grow or shrink after creation.
  • Prefer chaining map/filter/sortedBy over hand-written loops with a mutable accumulator — it is shorter, avoids off-by-one bugs, and states your intent directly.
  • Use getOrNull, firstOrNull, or lastOrNull instead of index access or first()/last() when the element might not exist, and handle the null with ?: or ?.let rather than !!.
  • Return List<T>, not MutableList<T>, from public functions unless callers genuinely need to mutate the result — this keeps your API’s contract honest.
  • Remember that val on a list only locks the reference, not the contents; if you need a real immutability guarantee, copy a mutable collection with toList() 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 a List, and it throws if the list is shorter.

Practice Exercises

  1. 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].
  2. 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.
  3. Write a function fun secondOrNull(list: List<Int>): Int? that returns the second element of a list, or null if 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> adds add, remove, and set — both are usually backed by the same array-based implementation.
  • Create lists with listOf(...) or mutableListOf(...); index with list[i], which desugars to get/set operator calls.
  • List element types are non-null by default — write List<String?> for a list of nullable elements, and List<String>? for a nullable reference to a list of non-null elements.
  • Operations like map, filter, and sortedBy return a brand-new list and never mutate the receiver.
  • A read-only List reference does not guarantee the underlying data is frozen — copy with toList() for a true snapshot.
  • Never mutate a MutableList while iterating it directly; use removeAll or build a new filtered list instead.