Mutable vs Read-Only Collections
Kotlin splits its collection types into two families: read-only interfaces like List, Set, and Map that only let you inspect data, and mutable interfaces like MutableList, MutableSet, and MutableMap that also let you add, remove, and update elements. This is a different axis from val versus var – a val can still hold a mutable collection whose contents change over time. Getting this distinction right matters because it is how Kotlin encourages safer APIs: a function that only needs to read a list should ask for a List, never exposing the ability to secretly mutate the caller’s data. Get it wrong and you will either fight the compiler over a missing add method, or get burned by a collection that changes underneath you when you least expect it.
Overview: How It Works
Kotlin’s collections are built on a family of interfaces in kotlin.collections. At the root sit Iterable and Collection; from there the API splits into a read-only branch – List, Set, Map – and a mutable branch that extends the read-only one – MutableList, MutableSet, MutableMap. Every method that changes contents (add, remove, clear, set, put, and so on) lives only on the mutable interfaces. The read-only interfaces expose just the inspection API: size, get, contains, iterator, and similar.
Here is the crucial under-the-hood fact: this split is purely a compile-time construct. Kotlin compiles to JVM bytecode, and on the JVM there is no separate runtime class for "a read-only list" versus "a mutable list" – both List<T> and MutableList<T> compile down to the very same java.util.List. "Read-only" does not mean the object itself is frozen; it means the specific reference you are holding only exposes read operations to the compiler. The same underlying object might still be fully mutable through a different, more permissive reference to it – which is exactly the aliasing behavior demonstrated in Example 3 below.
The standard library gives you matching factory functions for each family. listOf(...) builds a List<T>; mutableListOf(...) builds a MutableList<T> (an ArrayList under the hood). setOf(...) / mutableSetOf(...) do the same for sets, backed by a LinkedHashSet by default so insertion order is preserved, and mapOf(...) / mutableMapOf(...) do it for maps, backed by a LinkedHashMap. More specialized constructors exist too – arrayListOf(...), hashSetOf(...), hashMapOf(...), sortedSetOf(...), sortedMapOf(...) – each pinning down a specific mutable implementation rather than just handing you "some MutableList".
val and var form a second, independent axis. val locks the reference – you cannot reassign it to point at a different collection – but says nothing about the object’s contents. var lets you reassign the reference to an entirely different collection. Combine the two axes and you get four real combinations: a val holding a List is fully locked from that reference (no reassignment, no mutation); a val holding a MutableList cannot be reassigned but its contents can still change; a var holding a List can be pointed at a different read-only list but never mutated in place; and a var holding a MutableList allows both. Prefer val for the reference itself in almost all cases – reach for var only when the code genuinely needs to swap in a whole new collection later, not merely to add or remove items from the existing one.
Finally, know the difference between a view and a copy. Assigning a MutableList to a List-typed variable creates a new reference to the same object – a view, not a copy – so changes made through the mutable reference are visible through the read-only one. To get an independent snapshot instead, use toList(), toMutableList(), toSet(), or toMap(), which allocate a brand-new collection and copy the elements into it.
Syntax
The general shape is a matched pair of factory functions per collection type – one read-only, one mutable:
val readOnly: List<Element> = listOf(item1, item2, item3)
val mutable: MutableList<Element> = mutableListOf(item1, item2, item3)
val readOnlySet: Set<Element> = setOf(item1, item2, item3)
val mutableSet: MutableSet<Element> = mutableSetOf(item1, item2, item3)
val readOnlyMap: Map<Key, Value> = mapOf(key1 to value1, key2 to value2)
val mutableMap: MutableMap<Key, Value> = mutableMapOf(key1 to value1, key2 to value2)
| Read-only factory | Returns | Mutable factory | Returns |
|---|---|---|---|
listOf(...) |
List<T> |
mutableListOf(...) |
MutableList<T> |
setOf(...) |
Set<T> |
mutableSetOf(...) |
MutableSet<T> |
mapOf(...) |
Map<K, V> |
mutableMapOf(...) |
MutableMap<K, V> |
| – | – | arrayListOf(...) |
ArrayList<T> |
Examples
Example 1: Read-only vs mutable basics
fun main() {
val readOnlyNumbers = listOf(1, 2, 3, 4, 5)
val mutableNumbers = mutableListOf(1, 2, 3, 4, 5)
println("Read-only: $readOnlyNumbers")
println("Mutable: $mutableNumbers")
mutableNumbers.add(6)
mutableNumbers.remove(1)
println("Mutable after changes: $mutableNumbers")
}
Output:
Read-only: [1, 2, 3, 4, 5]
Mutable: [1, 2, 3, 4, 5]
Mutable after changes: [2, 3, 4, 5, 6]
Both lists start out identical. Only mutableNumbers exposes add and remove; calling mutableNumbers.remove(1) removes the element with value 1 (not the item at index 1), because MutableList<Int>.remove takes an element, not a position. If you tried the same calls on readOnlyNumbers, the code would not compile – List simply has no add or remove member.
Example 2: val locks the reference, not the contents
fun main() {
val shoppingList = mutableListOf("Milk", "Eggs")
shoppingList.add("Bread")
shoppingList[0] = "Almond Milk"
println(shoppingList)
val fixedItems = listOf("Passport", "Ticket")
println(fixedItems)
}
Output:
[Almond Milk, Eggs, Bread]
[Passport, Ticket]
shoppingList is declared with val, so you can never write shoppingList = someOtherList again – but because its type is MutableList<String>, you can freely call add and use index assignment (shoppingList[0] = ..., which desugars to a call to set) to change its contents. fixedItems, by contrast, has type List<String>, so there is no mutating operation available on it at all.
Example 3: aliasing – a read-only view of a mutable collection
fun printTotal(prices: List<Int>): Int {
var sum = 0
for (price in prices) {
sum += price
}
return sum
}
fun main() {
val cart = mutableListOf(250, 499, 999)
println("Total: ${printTotal(cart)}")
cart.add(150)
println("Total after adding an item: ${printTotal(cart)}")
val readOnlyView: List<Int> = cart
cart.add(50)
println("Read-only view reflects the change: $readOnlyView")
}
Output:
Total: 1748
Total after adding an item: 1898
Read-only view reflects the change: [250, 499, 999, 150, 50]
This is the example that matters most. printTotal declares its parameter as List<Int>, promising it will not mutate the caller’s data – and it cannot, because List offers no mutating methods. But no copy is made when cart is passed in or assigned to readOnlyView; both are just new references to the exact same ArrayList object that cart points to. When cart.add(50) runs, readOnlyView immediately reflects it, because "read-only" describes what you can do through that reference, not a guarantee about the object itself.
How It Works Step by Step
Walking through Example 3 in order: (1) mutableListOf(250, 499, 999) allocates one ArrayList object and cart is bound to it. (2) Passing cart into printTotal(prices: List<Int>) does not copy anything – it hands the same object to the function under a narrower, read-only-typed parameter, so the compiler will reject any attempt inside printTotal to mutate prices. (3) The for loop iterates and sums the current contents. (4) cart.add(150) mutates the one shared object in place, through the only reference in scope that is typed to allow it. (5) val readOnlyView: List<Int> = cart creates a second reference to that same object, just typed more restrictively. (6) cart.add(50) mutates the shared object again; since readOnlyView points at that same object, printing it shows the update too – the restriction lived in the type of the reference, never in the object.
Common Mistakes
Mistake 1: trying to reassign a val collection
val numbers = mutableListOf(1, 2, 3)
numbers = mutableListOf(4, 5, 6) // Error: Val cannot be reassigned
val forbids rebinding the reference, full stop – it does not matter that both sides are mutable lists. If the goal is to replace the contents, mutate the existing object instead:
val numbers = mutableListOf(1, 2, 3)
numbers.clear()
numbers.addAll(listOf(4, 5, 6))
println(numbers)
Output: [4, 5, 6]
Mistake 2: calling a mutating method on a List-typed parameter
fun addItem(items: List<String>) {
items.add("New Item") // Error: unresolved reference 'add'
}
List simply has no add member, so this fails to compile. Either accept a MutableList if the function is genuinely meant to mutate the caller’s collection, or – usually the better design – keep the read-only parameter and return a new collection instead of mutating in place:
fun addItem(items: List<String>): List<String> {
return items + "New Item"
}
fun main() {
val original = listOf("Pen", "Pencil")
val updated = addItem(original)
println(original)
println(updated)
}
Output:
[Pen, Pencil]
[Pen, Pencil, New Item]
The + operator on a List builds and returns a brand-new list; original is left untouched, which is usually exactly what callers expect from a function that only received a read-only reference.
Mistake 3: assuming a List reference is an independent snapshot
As Example 3 showed, assigning a MutableList to a List-typed variable does not copy anything – it is still the same object. If you need a real, independent snapshot, copy explicitly with toList():
val cart = mutableListOf(10, 20, 30)
val snapshot = cart.toList()
cart.add(40)
println(snapshot)
println(cart)
Output:
[10, 20, 30]
[10, 20, 30, 40]
Unlike plain assignment, toList() allocates a new list and copies the current elements into it, so later changes to cart have no effect on snapshot.
Mistake 4: unsafely casting List back to MutableList
val numbers: List<Int> = listOf(1, 2, 3)
val mutableNumbers = numbers as MutableList<Int>
mutableNumbers.add(4)
println(mutableNumbers)
This compiles (with an unchecked-cast warning), because at the bytecode level List and MutableList are the same runtime type – but it is not safe. listOf() with more than one element returns a fixed-size list under the hood that does not support structural changes. Running this code throws UnsupportedOperationException at the add call, because the underlying object genuinely refuses mutation, cast or no cast. If you need a mutable collection, build one with mutableListOf() from the start rather than casting your way into one.
Best Practices
- Prefer
List,Set, andMapfor function parameters and return types; only ask for the mutable interface when the function genuinely needs to add, remove, or clear. - Never treat a read-only type as a guarantee of deep immutability or thread safety – it only restricts what you can do through that particular reference, not what the underlying object supports.
- When you need an independent snapshot rather than a live view, copy explicitly with
toList(),toSet(), ortoMap()instead of relying on assignment. - Avoid casting a
Listto aMutableList– if mutation is required, construct aMutableListin the first place. - Default to
valfor the reference itself; reach forvaronly when the code must point at an entirely different collection later, as opposed to modifying the current one’s contents. - Expose only read-only types from public API boundaries, even if a mutable builder was used internally to assemble the result.
Practice Exercises
- Write a function
addToCart(cart: MutableList<String>, item: String)that addsitemtocart, and a separate functionviewCart(cart: List<String>)that only prints it. Call both frommainand print the cart before and after adding an item. - Predict the output: create a
mutableListOfof integers, assign it to avalof typeList<Int>, then calladdthrough the original mutable reference. What does printing theList-typed variable show, and why? - Write a function that takes a
List<Int>and returns a newList<Int>with every value doubled (usemap, which needs no mutability at all). Print both the original and doubled lists to confirm the original was never touched.
Summary
List,Set, andMapare read-only interfaces;MutableList,MutableSet, andMutableMapextend them with methods that change contents.- The split exists only at compile time – on the JVM both compile down to the same runtime type, so a "read-only" reference can still alias a mutable object that changes through another reference.
valandvarcontrol whether the reference can be reassigned; they say nothing about whether the collection’s contents can change.- Assigning a mutable collection to a read-only-typed variable creates a view of the same object, not a copy – use
toList()/toSet()/toMap()for a real snapshot. - Casting a
Listto aMutableListcan compile yet throwUnsupportedOperationExceptionat runtime if the underlying object is genuinely fixed-size. - Favor read-only types in public function signatures and reserve mutable types for code that truly owns the mutation.
