Maps

A Map in Kotlin holds a collection of key-value pairs, where every key is unique and points to exactly one value. Maps are the tool of choice whenever you need to look something up by an identifier — a username to a user record, a word to how many times it appears, a country to its capital — instead of scanning a list item by item. Kotlin splits maps into a read-only Map interface and a separate MutableMap interface for maps you intend to change, and unlike Java’s plain HashMap, Kotlin’s default map implementations preserve insertion order, so the output of your programs stays predictable.

Overview: How Maps Work

Kotlin’s kotlin.collections.Map<K, out V> is a read-only view of key-value pairs, exactly the same idea as List being a read-only view of a collection. It exposes get, size, keys, values, entries, and lookup helpers, but no way to insert or remove entries. MutableMap<K, V> extends it and adds put, the [key] = value operator, remove, and clear. When you call mapOf(...) or mutableMapOf(...), Kotlin builds the map on top of Java’s LinkedHashMap, which is why iteration order matches insertion order. If you explicitly want order to be irrelevant (and slightly faster lookups with no ordering overhead) you can use hashMapOf(...), which is backed by a plain HashMap whose iteration order is unspecified. For keys you want automatically sorted, there’s sortedMapOf(...), backed by a TreeMap.

Under the hood, maps are hash tables: each key’s hashCode() determines which internal bucket it lands in, and equals() is used to confirm an exact match within that bucket. That gives average O(1) time for get, put, and remove. This has a real consequence for what makes a good key: the key’s hashCode() and equals() must stay consistent for as long as the key lives in the map. A data class is an excellent key type because Kotlin generates structural equals()/hashCode() for you automatically. A mutable object used as a key is dangerous — if you mutate a field that participates in hashCode() after inserting it, the map can no longer find the entry, because it looks in the bucket for the new hash while the entry still lives in the bucket for the old hash.

Null safety interacts with maps in a subtle way worth calling out early: map[key] always returns V?, a nullable type, regardless of whether V itself is nullable. That’s because the key might simply not be in the map, and the compiler forces you to handle that possibility — it has nothing to do with whether the values you store are allowed to be null.

Syntax

val readOnlyMap: Map<KeyType, ValueType> = mapOf(key1 to value1, key2 to value2)
val mutableMap: MutableMap<KeyType, ValueType> = mutableMapOf(key1 to value1)

val value = mutableMap[key]              // returns ValueType?
mutableMap[key] = newValue               // insert or update (operator set)
mutableMap.remove(key)                   // remove an entry
for ((key, value) in mutableMap) { ... }  // destructure while iterating
Member Purpose
get(key) / map[key] Returns the value, or null if the key is absent
getValue(key) Returns the value, or throws NoSuchElementException if absent
getOrDefault(key, default) Returns the value, or default if absent
getOrElse(key) { ... } Returns the value, or the result of a lambda if absent
getOrPut(key) { ... } Returns the value; if absent, computes it, inserts it, then returns it (mutable maps only)
containsKey / containsValue Membership checks
keys, values, entries Views of just the keys, just the values, or the key-value Map.Entry pairs
mapValues / mapKeys Build a new map with values/keys transformed
filterKeys / filterValues Build a new map keeping only matching entries

Examples

Example 1: Reading from a read-only Map

fun main() {
    val capitals = mapOf(
        "France" to "Paris",
        "Japan" to "Tokyo",
        "Egypt" to "Cairo"
    )

    val capital = capitals["Japan"]
    println(capital)

    val missing = capitals["Germany"]
    println(missing)

    println(capitals.getValue("France"))
    println(capitals.getOrDefault("Germany", "Unknown"))
}

Output:

Tokyo
null
Paris
Unknown

mapOf pairs up keys and values using the infix function to, which builds a Pair. capitals["Japan"] returns String?, not String, because the compiler can’t guarantee the key exists — here it does, so we get "Tokyo". capitals["Germany"] returns null since that key was never inserted, and println(null) simply prints the word null. getValue is the "I promise this key exists" version that throws instead of returning null, and getOrDefault lets you supply a fallback inline.

Example 2: Building and updating a MutableMap

fun main() {
    val scores = mutableMapOf<String, Int>()
    scores["Alice"] = 90
    scores["Bob"] = 82
    scores["Alice"] = 95 // overwrites the earlier value

    scores.putIfAbsent("Carol", 70)
    scores.putIfAbsent("Alice", 100) // no effect, Alice is already present

    println(scores)

    scores.remove("Bob")
    println(scores)

    val daveScore = scores.getOrPut("Dave") { 60 }
    println(daveScore)
    println(scores)
}

Output:

{Alice=95, Bob=82, Carol=70}
{Alice=95, Carol=70}
60
{Alice=95, Carol=70, Dave=60}

Note that scores is declared with val, yet we freely add, overwrite, and remove entries. That’s not a contradiction: val only forbids reassigning the reference scores to point at a different map object — it says nothing about the contents of that object. Since mutableMapOf gives us a genuinely mutable map, mutating its contents through [], putIfAbsent, remove, and getOrPut is entirely legal. Also notice the printed order: Alice stays first even after its value changes, because updating an existing key does not change its position — the backing LinkedHashMap only records the position a key was first inserted at.

Example 3: A realistic word-frequency counter

fun main() {
    val text = "the quick brown fox jumps over the lazy dog the fox runs"
    val wordCounts = mutableMapOf<String, Int>()

    for (word in text.split(" ")) {
        wordCounts[word] = wordCounts.getOrDefault(word, 0) + 1
    }

    for ((word, count) in wordCounts) {
        println("$word -> $count")
    }

    val mostCommon = wordCounts.entries.maxByOrNull { it.value }
    if (mostCommon != null) {
        println("Most common: ${mostCommon.key} (${mostCommon.value} times)")
    }

    val sorted = wordCounts.toList().sortedByDescending { (_, count) -> count }
    println(sorted)
}

Output:

the -> 3
quick -> 1
brown -> 1
fox -> 2
jumps -> 1
over -> 1
lazy -> 1
dog -> 1
runs -> 1
Most common: the (3 times)
[(the, 3), (fox, 2), (quick, 1), (brown, 1), (jumps, 1), (over, 1), (lazy, 1), (dog, 1), (runs, 1)]

This is the pattern real code uses maps for: accumulate counts keyed by something, then report on them. getOrDefault(word, 0) + 1 avoids ever touching a null value — if the word hasn’t been seen, we treat its count as 0 before adding one. Destructuring in for ((word, count) in wordCounts) works because iterating a Map yields Map.Entry objects, and Map.Entry provides component1()/component2() for the key and value. maxByOrNull scans the entries and returns the one with the largest value (or null for an empty map, hence the null check). Finally, toList() turns the map into a List<Pair<String, Int>> so we can sort it — maps themselves have no inherent sort order beyond insertion order.

How It Works Step by Step

Walking through Example 3: the loop splits the sentence into 12 words and, for each one, computes wordCounts.getOrDefault(word, 0) + 1 and stores it back under that key. The first time "the" is seen, it’s inserted with count 1 at the front of the map’s internal insertion order; every later occurrence of "the" just updates that same slot’s value without moving its position, which is why "the" still prints first even though it also appears later in the sentence. Words that appear once, like "quick" or "runs", get inserted with count 1 and are never touched again. Once the loop finishes, the map holds nine unique keys in first-seen order. maxByOrNull then walks those nine entries once, keeping track of the highest value seen so far, and returns the entry for "the" since 3 is the largest count. The final sort is a completely separate pass: toList() snapshots the entries as pairs, and sortedByDescending performs a stable sort by count, so entries with equal counts keep their original relative order.

Common Mistakes

Mistake 1: Reaching for !! instead of handling a missing key

fun main() {
    val ages = mapOf("Alice" to 30, "Bob" to 25)
    val bobAge: Int = ages["Bob"]!!
    println(bobAge)

    val carolAge: Int = ages["Carol"]!! // Carol isn't in the map
    println(carolAge)
}

Output:

25
Exception in thread "main" java.lang.NullPointerException (thrown by !! because ages["Carol"] is null)

This compiles cleanly because !! tells the compiler "trust me, this isn’t null" — but ages["Carol"] genuinely is null since "Carol" was never inserted, so the program crashes the moment it’s evaluated. The fix is to treat a missing key as an expected outcome, not an exceptional one, using ?: or a plain null check with smart-casting:

fun main() {
    val ages = mapOf("Alice" to 30, "Bob" to 25)
    val carolAge = ages["Carol"] ?: 0
    println(carolAge)

    val bobAge = ages["Bob"]
    if (bobAge != null) {
        println("Bob is $bobAge")
    }
}

Output:

0
Bob is 25

Mistake 2: Confusing a read-only Map with an immutable one

val scores = mapOf("Alice" to 90, "Bob" to 82)
scores["Alice"] = 95 // Compile error: Map has no operator fun 'set'

It’s tempting to assume mapOf gives you something you just haven’t tried to change yet. In fact Map<K, V> simply doesn’t declare a way to insert or update entries at all — the compiler rejects this before the program can even run, with an unresolved reference to a set operator. If you need to mutate the map, ask for a MutableMap from the start:

fun main() {
    val scores = mutableMapOf("Alice" to 90, "Bob" to 82)
    scores["Alice"] = 95
    println(scores)
}

Output:

{Alice=95, Bob=82}

Mistake 3: Using associateBy when keys aren’t actually unique

fun main() {
    val words = listOf("apple", "avocado", "banana", "blueberry")
    val byFirstLetter = words.associateBy { it.first() }
    println(byFirstLetter)
}

Output:

{a=avocado, b=blueberry}

This compiles and runs without any error, which makes it a sneaky mistake: associateBy builds a map keyed by the result of the lambda, and when two elements produce the same key, the later one silently overwrites the earlier one — "apple" is quietly lost. If what you actually want is every value that shares a key, use groupBy, which collects same-key values into a list instead of discarding all but the last:

fun main() {
    val words = listOf("apple", "avocado", "banana", "blueberry")
    val byFirstLetter = words.groupBy { it.first() }
    println(byFirstLetter)
}

Output:

{a=[apple, avocado], b=[banana, blueberry]}

Best Practices

  • Expose Map (not MutableMap) in function signatures and return types unless the caller genuinely needs to mutate it — it prevents accidental changes from far-away code.
  • Prefer data class instances or primitives as keys, since their equals()/hashCode() are correct and stable by construction.
  • Never mutate a field that affects a key’s hashCode() after that key has been inserted into a map — the entry becomes unreachable by lookup.
  • Use getOrDefault, getOrElse, or ?: for a missing key you expect to handle, and reserve getValue/!! for cases where a missing key truly indicates a bug.
  • Reach for groupBy when duplicate keys should retain every value, and only use associateBy when you’re confident the keys are unique.
  • Use mapValues, mapKeys, filterKeys, and filterValues to transform a map functionally instead of building a new mutable map by hand in a loop.
  • Pick hashMapOf when order truly doesn’t matter, the mapOf/mutableMapOf default when insertion order should be preserved, and sortedMapOf when you need keys in sorted order.

Practice Exercises

  • Write a program that counts how many times each vowel (a, e, i, o, u) appears in a sentence, using a MutableMap<Char, Int>, then prints each vowel with its count.
  • Given val prices = mapOf("Pen" to 1.5, "Notebook" to 3.2, "Eraser" to 0.75), write code that finds and prints the name of the most expensive product using maxByOrNull.
  • Write a function that takes two Map<String, Int> arguments and returns a new map where, for any key present in both, the values are summed, and keys present in only one map keep their original value. Hint: start a new mutableMapOf from the first map’s entries, then loop over the second map’s entries using getOrDefault to add on top.

Summary

  • Map<K, V> is a read-only view of key-value pairs; MutableMap<K, V> adds insertion, updating, and removal.
  • mapOf/mutableMapOf preserve insertion order (backed by LinkedHashMap); hashMapOf does not; sortedMapOf keeps keys sorted.
  • map[key] always returns a nullable type, because the key might not exist — handle it with ?:, getOrDefault, or a null check rather than !!.
  • A val map reference can’t be reassigned, but a MutableMap‘s contents can still change freely — val is not deep immutability.
  • Good map keys have stable equals()/hashCode(); data classes are ideal, mutated objects are dangerous.
  • associateBy silently drops duplicate-key elements; groupBy keeps them all as a list.