map, filter, and forEach

map, filter, and forEach are three of the most-used functions in Kotlin’s collections API. Together they let you transform, select, and act on the elements of a list without writing manual for loops full of index bookkeeping and temporary mutable variables. Once you understand how they work you will reach for them constantly, because they make everyday data-shuffling code shorter, safer, and easier to read at a glance.

Overview / How it works

map, filter, and forEach are not language keywords — they are ordinary extension functions defined on Iterable<T> (and, with the same names, on Sequence<T> and arrays) in the Kotlin standard library. Each one takes a lambda and applies it to every element of a collection, but they differ in what they do with the lambda’s result:

  • map calls the lambda on each element and collects the return values into a brand-new List. The output list is the same size as the input, but its element type can be completely different (for example, List<Person> in, List<String> out).
  • filter calls a lambda that returns Boolean (a predicate) on each element and keeps only the elements for which it returned true, again producing a new List of the same element type, possibly shorter.
  • forEach calls the lambda purely for its side effect (printing, logging, updating an external variable). Its lambda returns Unit, and forEach itself returns Unit — there is no new collection to capture.

A crucial point that trips up many newcomers: none of these functions mutate the collection they are called on. map and filter always return a new, read-only List; the original collection is left completely untouched, even if it was a MutableList. This follows Kotlin’s general preference for immutable data — you build new collections from old ones instead of editing in place.

Under the hood, map, filter, and forEach are declared with the inline modifier. That tells the compiler to paste the lambda’s bytecode directly at the call site instead of allocating a separate function object to hold it, so a chain like list.filter { ... }.map { ... } has essentially the same runtime cost as a hand-written loop — you get readability without paying a performance tax. The trade-off is that each element in the original collection is fully visited by filter before map even starts on the result; if you chain many operations over very large collections and want each element to flow through the whole pipeline one at a time, that’s what asSequence() and lazy Sequence operations are for — a topic covered in its own lesson.

Syntax

Simplified versions of the real standard-library declarations look like this:

// General form (simplified signatures from kotlin.collections)
inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R>
inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T>
inline fun <T> Iterable<T>.forEach(action: (T) -> Unit)
Function Purpose Returns
map Transform each element into something else New List<R>
filter Keep only elements matching a predicate New List<T>
forEach Perform a side effect for each element Unit (nothing)
mapNotNull Transform, dropping any null results New non-null List<R>
filterNot Keep elements that do NOT match a predicate New List<T>

Inside the lambda, when there is exactly one parameter you can refer to it implicitly as it instead of naming it, and because these are the last (and only) parameter, Kotlin lets you write the lambda outside the parentheses — that’s why you see list.map { it * 2 } instead of list.map({ it -> it * 2 }).

Examples

Example 1: map — transforming numbers

fun main() {
    val numbers = listOf(1, 2, 3, 4, 5)
    val squares = numbers.map { it * it }
    println(squares)
}

Output:

[1, 4, 9, 16, 25]

map visits every element of numbers in order, squares it, and collects the five results into a new List<Int> called squares. The original numbers list is unchanged and still holds [1, 2, 3, 4, 5].

Example 2: filter — keeping only some elements

fun main() {
    val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
    val evens = numbers.filter { it % 2 == 0 }
    println(evens)
}

Output:

[2, 4, 6, 8, 10]

The predicate { it % 2 == 0 } is evaluated for each of the ten numbers; only the ones where it returns true survive into the new list. Order is preserved, and the list can be shorter than the original — here it goes from ten elements down to five.

Example 3: forEach — acting on each element

fun main() {
    val fruits = listOf("apple", "banana", "cherry")
    fruits.forEach { fruit ->
        println("I like $fruit")
    }
}

Output:

I like apple
I like banana
I like cherry

Here the lambda parameter is named explicitly as fruit instead of using the implicit it, purely for readability. forEach produces no new collection — it exists only to run println once per element, in the original order.

Example 4: chaining all three together

data class Person(val name: String, val age: Int)

fun main() {
    val people = listOf(
        Person("Alice", 30),
        Person("Bob", 15),
        Person("Charlie", 25),
        Person("Dana", 17)
    )

    val adultNames = people
        .filter { it.age >= 18 }
        .map { it.name.uppercase() }

    adultNames.forEach { println(it) }
}

Output:

ALICE
CHARLIE

This is the realistic shape these functions take in day-to-day code: filter narrows the list of people down to adults (Alice and Charlie), map transforms the survivors into their upper-cased names, and forEach is used only at the very end, once, to print the final result. Person is a data class, so it automatically gets a readable toString(), structural equals()/hashCode(), and a copy() function — none of which this particular example needs, but which come for free the moment you model data with a data class instead of a plain class.

How it works step by step

For the chain in Example 4, execution proceeds like this:

  • 1. filter iterates people from first to last, evaluating it.age >= 18 for each. It builds a brand-new list containing only Person("Alice", 30) and Person("Charlie", 25), in that order.
  • 2. map then iterates that intermediate two-element list, calling it.name.uppercase() on each Person, producing a new List<String> containing "ALICE" and "CHARLIE".
  • 3. forEach iterates that final list and calls println once per element, causing the two lines of output.

Because map and filter are eager (not lazy), each stage fully finishes before the next one starts — filter allocates its whole intermediate list before map ever runs. For small and medium collections this is irrelevant; for very large pipelines with many chained steps, converting to a Sequence first with asSequence() avoids building those intermediate lists.

Common Mistakes

Mistake 1: using map for side effects instead of forEach

val numbers = listOf(1, 2, 3)
val result = numbers.map { println(it) }
// result is a List<Unit>: [kotlin.Unit, kotlin.Unit, kotlin.Unit]
// the return value is discarded -- a list nobody needs still gets built in memory

This compiles and even prints the numbers, but it’s misleading: map signals to readers that you care about the transformed values, yet here the resulting List<Unit> is thrown away. It also wastes memory building a list of nothing. When you only want a side effect, reach for forEach instead:

val numbers = listOf(1, 2, 3)
numbers.forEach { println(it) }

Mistake 2: assuming filter/map keep the MutableList type

val numbers = mutableListOf(1, 2, 3, 4, 5)
val evens = numbers.filter { it % 2 == 0 }
evens.add(6) // does not compile: filter returns List<Int>, not MutableList<Int>

Even though numbers is a MutableList, filter always returns the read-only List interface. The compiler correctly refuses to call add on it, because List exposes no mutating methods. If you need a mutable result, convert explicitly:

val numbers = mutableListOf(1, 2, 3, 4, 5)
val evens = numbers.filter { it % 2 == 0 }.toMutableList()
evens.add(6)
println(evens)

Output:

[2, 4, 6]

Mistake 3: reaching for !! inside map instead of handling nulls

val names: List<String?> = listOf("Ann", null, "Bob")
val lengths = names.map { it!!.length }
println(lengths)

This compiles, because !! is legal on any nullable type — but it throws a NullPointerException at runtime the instant map reaches the null element, crashing the whole program over one bad entry. The non-nullable-by-default type system is Kotlin’s headline safety feature; using !! here throws that safety away instead of handling the null. Prefer mapNotNull, which safely evaluates a nullable expression per element and automatically drops any null results:

val names: List<String?> = listOf("Ann", null, "Bob")
val lengths = names.mapNotNull { it?.length }
println(lengths)

Output:

[3, 3]

The ?.length safe call returns null for the null element instead of crashing, and mapNotNull filters that null out of the final list rather than including it.

Best Practices

  • Use forEach only for side effects (printing, logging, mutating something outside the lambda); use map only when you actually need the transformed list it returns.
  • Chain filter before map when you need both, so you transform only the elements you’re keeping instead of transforming everything and filtering afterward.
  • Prefer a regular for loop over forEach when you need to break or continue partway through — forEach‘s lambda cannot use non-local break/continue.
  • Reach for mapNotNull, filterNotNull, or safe calls (?., ?:) instead of !! when a collection may contain nulls.
  • Remember filter and map always return a new List, never mutate the receiver — if you need a mutable result, call .toMutableList() explicitly on the result.
  • For long chains over large collections, consider asSequence() to avoid allocating an intermediate list at every step.
  • Give lambda parameters explicit names (like fruit -> ...) instead of it whenever it meaningfully improves readability, especially in nested lambdas where a bare it would be ambiguous.

Practice Exercises

  • Exercise 1: Given val words = listOf("kotlin", "is", "fun", "to", "learn"), use filter to keep only the words with more than 2 characters, then map them to their lengths. Expected output: [6, 3, 5].
  • Exercise 2: Given val scores = listOf(55, 90, 62, 78, 40, 99), use filter to find the passing scores (>= 60), then use forEach to print each one on its own line prefixed with "Pass: ".
  • Exercise 3: Given a data class Product(val name: String, val price: Double) and a list of several products, use filter and map together to produce a List<String> of the names of all products priced under 20.0, then print that list. Try rewriting your map lambda to use it versus a named parameter and see which reads more clearly for your case.

Summary

  • map transforms every element into a new value and returns a new List of the results.
  • filter keeps only the elements matching a Boolean predicate and returns a new, possibly shorter List.
  • forEach performs a side effect per element and returns Unit — it never produces a new collection.
  • None of these functions mutate the original collection, even when called on a MutableListmap and filter always return the read-only List type.
  • They are inline functions, so chaining them costs about the same as a hand-written loop.
  • Prefer mapNotNull and safe calls over !! when working with nullable elements.
  • For very large collections or long chains, asSequence() avoids building intermediate lists at each step.