Lambda Expressions

A lambda expression is a small, unnamed block of code that you can store in a variable, pass as an argument, or return from a function — a function without a name, written inline exactly where you need it. Kotlin leans on lambdas everywhere: list.filter { ... }, list.map { ... }, and most of the standard library’s collection operations accept a lambda as their final argument. If you know Java, a lambda replaces the ceremony of an anonymous inner class implementing a single abstract method with a few characters between curly braces; if lambdas are new to you entirely, think of them as "executable values" — code that behaves like data you can pass around.

Overview: How Lambda Expressions Work

A lambda’s type in Kotlin is called a function type, written as (ParameterTypes) -> ReturnType. For example, (Int, Int) -> Int describes a lambda (or any function) that takes two Int parameters and returns an Int. This is a real type, just like String or List<Int> — you can declare a variable of that type, store a lambda in it, pass it as a function parameter, or return it from another function. A function that accepts or returns another function (or a lambda) is called a higher-order function; filter, map, sortedBy, and forEach are all higher-order functions in the standard library.

Under the hood, a lambda is compiled into an object implementing one of the compiler-generated FunctionN interfaces (Function0, Function1, Function2, and so on, one per parameter count), each with a single invoke() method. Calling a lambda like square(5) is really calling square.invoke(5) under the covers. This means an ordinary lambda allocates an object at runtime — usually negligible, but it matters for hot loops, which is one reason the standard library marks so many of its lambda-taking functions (filter, map, forEach, and friends) as inline: the compiler copies the lambda’s code directly into the call site instead of creating an object, eliminating the allocation entirely and, as a bonus, allowing a bare return inside the lambda to exit the enclosing function (a non-local return) — something that is illegal in a lambda passed to a non-inline function, as you’ll see in Common Mistakes.

Lambdas also form closures: they can read and even mutate variables from the scope where they were created, and that scope stays alive for as long as the lambda does, even after the enclosing function has returned. This is what lets a lambda saved in a list, passed to a background task, or returned from a factory function still see up-to-date values of variables it captured.

Kotlin also supports anonymous functions (fun(x: Int): Int { return x * x }) as an alternative to lambdas — they behave similarly but let you specify a return type explicitly and use a labeled-free return that always refers to the anonymous function itself, never a non-local return. Lambdas are far more common in idiomatic Kotlin because of their concise syntax, so this lesson focuses on them.

Syntax

The general form of a lambda expression is:

{ parameter1: Type1, parameter2: Type2 ->
    // body statements
    lastExpression // this is the returned value
}
Part Meaning
{ ... } Curly braces delimit the lambda — there are no parentheses around the parameter list like in a regular function.
parameter1: Type1, ... The parameter list. Types can usually be omitted when the compiler can infer them from context (for example, from the variable’s declared function type).
-> Separates the parameter list from the body. Omitted entirely for a zero-parameter lambda.
Body One or more statements. The value of the last expression in the body is the lambda’s return value — there is no return keyword needed for this.

When you declare a variable’s type explicitly, the compiler can infer the lambda’s parameter types, so you can drop them:

val sum: (Int, Int) -> Int = { a, b -> a + b }

Trailing lambda syntax

If a function’s last parameter has a function type, you can pass the lambda outside the parentheses: calculate(4, 5) { x, y -> x + y } instead of calculate(4, 5, { x, y -> x + y }). If the lambda is the function’s only parameter, you can drop the parentheses completely: numbers.filter { it % 2 == 0 }. This trailing lambda style is idiomatic Kotlin and shows up constantly in real code.

The implicit parameter it

When a lambda has exactly one parameter and you don’t name it, Kotlin lets you refer to it as it: numbers.map { it * it } is shorthand for numbers.map { x -> x * x }. This is convenient for short, single-purpose lambdas, but once you name a parameter explicitly, it is no longer available for that lambda.

Examples

Example 1: A lambda stored in a variable

fun main() {
    val square: (Int) -> Int = { x -> x * x }
    println(square(5))
}

Output:

25

square is a variable whose type is the function type (Int) -> Int. The lambda { x -> x * x } is assigned to it, and calling square(5) invokes the lambda just like calling an ordinary function, producing 25.

Example 2: Trailing lambdas with standard library functions

fun main() {
    val numbers = listOf(1, 2, 3, 4, 5, 6)
    val evenSquares = numbers.filter { it % 2 == 0 }.map { it * it }
    println(evenSquares)
}

Output:

[4, 16, 36]

filter keeps only the elements where the lambda returns true — here, the even numbers 2, 4, 6. map then transforms each remaining element using its own lambda, squaring them. Both lambdas use the implicit it parameter, and both are written using trailing lambda syntax since each is the sole (and last) argument.

Example 3: Writing your own higher-order function

fun calculate(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
    return operation(a, b)
}

fun main() {
    val sum = calculate(4, 5) { x, y -> x + y }
    val product = calculate(4, 5) { x, y -> x * y }
    println("Sum: $sum, Product: $product")
}

Output:

Sum: 9, Product: 20

calculate takes two numbers and an operation parameter of type (Int, Int) -> Int. Each call passes a different lambda as a trailing lambda, so the exact same function runs either addition or multiplication depending on which lambda it’s handed — the core idea behind higher-order functions: behavior itself becomes a parameter.

Example 4: Closures — capturing and mutating outer state

fun main() {
    var counter = 0
    val increment: () -> Unit = { counter++ }
    increment()
    increment()
    increment()
    println("Counter: $counter")
}

Output:

Counter: 3

The lambda assigned to increment captures the local var counter from its enclosing scope. Each call to increment() mutates that same captured variable, and the change is visible outside the lambda once it returns. This only works because counter is a var; a lambda can read a captured val but obviously can’t reassign it.

How It Works Step by Step

Tracing Example 3’s calculate(4, 5) { x, y -> x + y } call:

  • The trailing { x, y -> x + y } block is compiled into an object implementing Function2<Int, Int, Int>, whose invoke(x, y) method runs x + y.
  • That object is passed as the operation argument, alongside a = 4 and b = 5.
  • Inside calculate, operation(a, b) calls operation.invoke(4, 5), running the lambda’s body with x = 4, y = 5.
  • The lambda’s last (and only) expression, x + y, evaluates to 9, which becomes the lambda’s return value.
  • calculate returns that 9 to main, where it’s stored in sum.
  • The exact same sequence repeats for the multiplication lambda, producing 20 for product.

Because filter, map, and forEach in the standard library are declared inline, the compiler skips the object-and-invoke() step for them entirely and splices the lambda’s bytecode directly into the loop that implements the function — the lambdas in Example 2 never actually become Function1 objects at runtime.

Common Mistakes

Mistake 1: Using return inside a lambda passed to a non-inline function

A bare return inside a lambda tries to exit the enclosing function, not just the lambda — but that’s only legal when the lambda is passed to an inline function, because only then does the compiler know exactly where the lambda’s code ends up. Passing the same pattern to an ordinary function fails to compile:

fun forEachItem(items: List<Int>, action: (Int) -> Unit) {
    for (item in items) {
        action(item)
    }
}

fun printFirstEven(numbers: List<Int>) {
    forEachItem(numbers) {
        if (it % 2 == 0) {
            println("First even: $it")
            return // Error: 'return' is not allowed here
        }
    }
    println("No even number found")
}

fun main() {
    printFirstEven(listOf(1, 3, 5, 4, 7))
}

The fix is to mark the higher-order function inline, which both enables the non-local return and inlines the lambda’s code at the call site:

inline fun forEachItem(items: List<Int>, action: (Int) -> Unit) {
    for (item in items) {
        action(item)
    }
}

fun printFirstEven(numbers: List<Int>) {
    forEachItem(numbers) {
        if (it % 2 == 0) {
            println("First even: $it")
            return
        }
    }
    println("No even number found")
}

fun main() {
    printFirstEven(listOf(1, 3, 5, 4, 7))
}

Output:

First even: 4

Because forEachItem is now inline, the return exits printFirstEven the moment the first even number is found, skipping both the remaining loop items and the trailing println. This is exactly how the standard library’s own forEach behaves, since it’s declared inline too.

Mistake 2: Shadowing it in nested lambdas

When lambdas nest, the innermost it always wins, silently shadowing the outer one — this compiles cleanly but produces a logic bug:

fun main() {
    val numbers = listOf(1, 2, 3)
    val nested = listOf(10, 20)
    val wrong = numbers.map {
        nested.map { it * 2 }
    }
    println(wrong)
}

Output:

[[20, 40], [20, 40], [20, 40]]

Inside the inner lambda, it refers to nested‘s element, not numbers‘s — the outer element is never used, so every outer iteration produces the same inner list. Naming the outer parameter explicitly fixes it and makes the intent obvious:

fun main() {
    val numbers = listOf(1, 2, 3)
    val nested = listOf(10, 20)
    val correct = numbers.map { n ->
        nested.map { n + it }
    }
    println(correct)
}

Output:

[[11, 21], [12, 22], [13, 23]]

Mistake 3: Capturing a shared var instead of a per-iteration value

A lambda captures the variable itself, not a snapshot of its value at creation time. Capturing a mutable loop variable that lives outside the loop means every lambda ends up sharing the same final value:

fun main() {
    val actions = mutableListOf<() -> Int>()
    var counter = 0
    for (i in 1..3) {
        counter = i
        actions.add { counter }
    }
    println(actions.map { it() })
}

Output:

[3, 3, 3]

All three lambdas close over the same counter, which is 3 by the time any of them actually run. Capturing a fresh val created inside the loop body instead gives each lambda its own value:

fun main() {
    val actions = mutableListOf<() -> Int>()
    for (i in 1..3) {
        val captured = i
        actions.add { captured }
    }
    println(actions.map { it() })
}

Output:

[1, 2, 3]

Note that Kotlin’s for (i in 1..3) loop variable i is itself already a fresh val on every iteration, so capturing i directly (instead of copying it into counter) would also have worked — the bug in the first version comes specifically from routing the value through an outer var.

Best Practices

  • Use trailing lambda syntax whenever a lambda is the last (or only) parameter — it’s the idiomatic Kotlin style and what you’ll see throughout the standard library.
  • Name lambda parameters explicitly as soon as you nest lambdas or the meaning of it becomes unclear; don’t rely on implicit it for anything beyond a single short, obvious operation.
  • Keep lambdas short. If a lambda body grows past a few lines, extract it into a named function or method reference — it reads better and is easier to test.
  • Mark your own higher-order functions inline when the lambda parameter needs to support non-local return, or when the function is small and called in a hot path where avoiding an object allocation matters. Don’t reflexively inline large functions — it duplicates bytecode at every call site.
  • Prefer standard library functions (map, filter, fold, sortedBy, and so on) over hand-written loops when a lambda expresses the intent more directly — they’re inline, well-tested, and communicate intent at a glance.
  • Remember that a lambda captures variables by reference, not by value snapshot — be deliberate about capturing a var versus a freshly created val per iteration.

Practice Exercises

  • Write a function repeatAction(times: Int, action: () -> Unit) that calls action the given number of times, then call it with a trailing lambda that prints "Hi" three times.
  • Given val words = listOf("kotlin", "fun", "lambda", "is", "great"), use filter and map lambdas to produce a list of the uppercase versions of every word with more than 4 letters. Expected output: [KOTLIN, LAMBDA, GREAT].
  • Declare a variable average of type (Double, Double) -> Double, assign it a lambda that computes the average of its two parameters, and print the result of calling it with 4.0 and 7.0.

Summary

  • A lambda expression is an unnamed block of code with the syntax { params -> body }; its type is a function type like (Int, Int) -> Int.
  • A function that takes or returns a lambda is a higher-order function; the standard library’s filter, map, and forEach are all examples.
  • If the lambda is the last parameter, it can be written as a trailing lambda outside the parentheses; if it’s the only parameter, the parentheses can be dropped entirely.
  • A single unnamed lambda parameter can be referred to as it, but naming parameters explicitly avoids confusion once lambdas nest.
  • Lambdas form closures: they can read and mutate vars from their enclosing scope, and that scope stays alive as long as the lambda does.
  • A bare return inside a lambda only works (as a non-local return) when the lambda is passed to an inline function; otherwise it’s a compile error.
  • Watch out for shadowed it in nested lambdas and for capturing a shared var instead of a fresh per-iteration val.