Question

When to Use run, let, apply, also, and with in Kotlin

Question

I would like clear examples showing when to use each of Kotlin’s scope functions: run, let, apply, also, and with.

I have already read explanations about the differences between these functions, but I still do not have practical examples that make their use cases easy to understand.

Short Answer

By the end of this page, you will understand what Kotlin scope functions do, how run, let, apply, also, and with differ, and when each one is the best fit. You will also see practical examples, common mistakes, and a small project that helps you use them naturally in real Kotlin code.

Concept

Kotlin’s scope functions let you execute a block of code using an object in a shorter, more readable way.

The five commonly used scope functions are:

  • let
  • run
  • apply
  • also
  • with

They all help you work with an object inside a block, but they differ in two important ways:

  1. How the object is referenced inside the block

    • As it
    • As this
  2. What the function returns

    • The object itself
    • The result of the block

That is the whole idea.

Why this matters

In real code, these functions help with tasks like:

  • configuring objects
  • avoiding repeated variable names
  • handling nullable values safely
  • performing extra side effects such as logging
  • grouping related operations

The core differences

FunctionObject inside blockReturnsCommon use
letitlambda resultnull checks, short transformations
runthislambda resultobject operations that compute a result
applythisobject itselfobject setup/configuration
alsoitobject itselfside effects like logging or debugging
withthislambda resultoperate on an existing object without extension chaining

A simple rule

  • Need a result from the block? Think let, run, or with.
  • Need the same object back? Think apply or also.
  • Want to configure fields? apply
  • Want to safely use a nullable object? let
  • Want to do extra side effects? also
  • Want to run multiple member calls and return a computed value? run or with

These functions matter because they make Kotlin code more expressive, but only when used clearly. Overusing them can make code harder to read, so choosing the right one is important.

Mental Model

Think of a scope function like inviting an object into a small workspace.

You choose:

  • what name the object has in the workspace: this or it
  • what you take out of the workspace: the object itself or a new result

Analogy: working at a desk

Imagine you place an object on your desk.

  • apply: You adjust the object, set it up, and then take the same object away.
  • also: You keep the object the same, but make notes about it on the side, then take the same object away.
  • let: You examine the object and produce some new result.
  • run: You work with the object closely as if it were your current context, and produce some new result.
  • with: Similar to run, but you already have the object and say, “work with this object for a moment.”

Quick intuition

  • apply = configure this object
  • also = do something extra with this object
  • let = use this object and produce a result
  • run = operate in this object’s context and produce a result
  • with = group operations on this object and produce a result
Take Quiz

Syntax and Examples

1. let

let uses it and returns the result of the block.

val name: String? = "Kotlin"

val length = name?.let {
    it.length
}

println(length) // 6

This is common for nullable values. If name is not null, the block runs. The result is the length.

2. run

run uses this and returns the result of the block.

data class User(val firstName: String, val lastName: String)

val user = User("Ada", "Lovelace")

val fullName = user.run {
    "$firstName $lastName"
}

println(fullName) // Ada Lovelace

Inside run, you can access members directly because the receiver is this.

3. apply

apply uses this and returns the object itself.

data class Person(var name: String = "", var age: Int = 0)

val person = Person().apply {
    name = "Sam"
    age = 25
}

println(person) // Person(name=Sam, age=25)

This is perfect for object configuration.

4. also

also uses it and returns the object itself.

val numbers = mutableListOf(1, 2, 3)
    .also {
        println("Before adding: $it")
    }

numbers.add(4)
println(numbers) // [1, 2, 3, 4]

also is useful for side effects like logging, validation messages, or debugging.

5. with

with uses this and returns the result of the block.

data class Car(val brand: String, val year: Int)

val car = Car("Toyota", 2020)

val description = with(car) {
    "$brand ($year)"
}

println(description) // Toyota (2020)

with is not an extension function. You pass the object as an argument.

One example with all five

data class Config(var host: String = "", var port: Int = 0)

val config = Config().apply {
    host = "localhost"
    port = 8080
}.also {
    println("Created config: $it")
}

val address = config.run {
    "$host:$port"
}

val hostLength = config.host.let {
    it.length
}

val summary = with(config) {
    "Server runs on $host:$port"
}

println(address)
println(hostLength)
println(summary)

This shows the pattern clearly:

  • apply configures
  • also logs
  • run computes using object context
  • let transforms a value
  • with groups work on an object

Step by Step Execution

Consider this example:

data class Box(var value: Int = 0)

val result = Box().apply {
    value = 10
}.also {
    println("Box after apply: $it")
}.run {
    value * 2
}

println(result)

Step by step

  1. Box() creates a new object:
Box(value = 0)
  1. apply { value = 10 } runs.
    • Inside apply, this is the Box object.
    • value becomes 10.
    • apply returns the same object.

Now the object is:

Box(value = 10)
  1. also { println("Box after apply: $it") } runs.
    • Inside also, the object is available as it.
    • It prints the object.
    • also returns the same object unchanged.

Printed output so far:

Box after apply: Box(value=10)
  1. run { value * 2 } runs.

    • Inside run, this is the Box object.
    • value * 2 becomes 20.
    • run returns 20.
  2. result gets the value 20.

  3. println(result) prints:

20

Final output

Box after apply: Box(value=10)
20

What this example teaches

  • apply changes the object
  • also performs a side effect
  • run computes and returns a final result

Real World Use Cases

let

Useful when working with nullable values or performing a short transformation.

val email: String? = getEmailFromApi()

val domain = email?.let {
    it.substringAfter("@")
}

Common uses:

  • null-safe processing
  • converting one value into another
  • limiting variable scope

run

Useful when you want to do several operations on an object and return one computed result.

data class Order(val price: Double, val tax: Double)

val order = Order(100.0, 10.0)
val total = order.run {
    price + tax
}

Common uses:

  • deriving values from an object
  • formatting output
  • combining several member accesses into one result

apply

Useful for building or configuring objects.

val intent = android.content.Intent().apply {
    action = "OPEN_PROFILE"
    putExtra("userId", 42)
}

Common uses:

  • creating DTOs or models
  • setting Android views, intents, fragments, dialogs
  • test data setup

also

Useful for side effects that should not change the main flow.

val user = loadUser()
    .also { println("Loaded user: $it") }

Common uses:

  • logging
  • debugging
  • analytics tracking
  • sanity checks during a chain

with

Useful when you already have an object and want to group related operations.

data class Report(val title: String, val pages: Int)

val report = Report("Annual", 12)
val text = with(report) {
    "Report: $title, pages: $pages"
}

Common uses:

  • formatting text from object properties
  • reducing repeated object names
  • grouped member access without chaining
Take Quiz

Real Codebase Usage

In real projects, developers usually choose scope functions based on intent, not just syntax.

1. Object configuration with apply

A very common pattern is object creation plus setup.

data class ApiConfig(var baseUrl: String = "", var timeout: Int = 0)

val config = ApiConfig().apply {
    baseUrl = "https://api.example.com"
    timeout = 30
}

This reads like: create this object, configure it, and keep it.

2. Null handling with let

Developers often use let after a safe call.

val token: String? = getToken()

token?.let {
    println("Using token: $it")
}

This avoids manual null checks.

3. Side effects with also

also is common in chains where you want extra behavior without changing the result.

val user = fetchUserById(1)
    .also { println("Fetched user: $it") }

This is useful for:

  • logging
  • performance tracing
  • debug prints
  • analytics events

4. Computing values with run

run is common when code should read like “use this object to calculate something.”

data class Rectangle(val width: Int, val height: Int)

val rectangle = Rectangle(4, 5)
val area = rectangle.run {
    width * height
}

5. Grouping operations with with

When an object already exists, with can make a block cleaner.

data class Settings(val theme: String, val language: String)

val settings = Settings("dark", "en")
val label = with(settings) {
    "Theme=$theme, Language=$language"
}

Common project patterns

  • Builder-like setup: apply
  • Null-safe validation or transformation: let
  • Logging during pipelines: also
  • Produce a summary/result from an object: run
  • Avoid repeating an object name many times: with

Guard clause note

Scope functions should not replace simple code when simple code is clearer. For example, a plain if (value != null) may be better than chaining several nested scope functions. Real codebases value readability more than cleverness.

Common Mistakes

1. Using the wrong function for configuration

If you want to configure an object and keep that object, use apply, not run.

Broken idea:

data class User(var name: String = "")

val user = User().run {
    name = "Mia"
}

println(user) // user is Unit, not User

Why it fails:

  • run returns the last expression in the block
  • Here, the assignment returns Unit

Correct:

val user = User().apply {
    name = "Mia"
}

2. Using let when it makes code unclear

Too many nested it references become confusing.

val text: String? = "hello"
text?.let {
    println(it)
    listOf(it.length).also {
        println(it)
    }
}

This is hard to read because it means different things in different blocks.

Better:

text?.let { value ->
    println(value)
    listOf(value.length).also { lengths ->
        println(lengths)
    }
}

3. Using also to modify object properties as the main purpose

also can modify objects, but it is not the clearest choice for configuration.

Less clear:

val person = Person().also {
    it.name = "Ana"
    it.age = 30
}

Better:

val person = Person().apply {
    name = "Ana"
    age = 30
}

4. Confusing run and with

Both use this and return the block result.

  • Use run when calling on an object as an extension
  • Use with when passing the object into the function

Both can work, but consistency matters.

5. Overusing scope functions

This can make code harder to follow.

Overcomplicated:

val result = user?.let {
    it.profile?.run {
        name.also {
            println(it)
        }
    }
}

Sometimes this is clearer:

if (user != null && user.profile != null) {
    println(user.profile.name)
}

6. Forgetting what each function returns

This is the most common mistake.

Remember:

  • apply and also return the object
  • let, run, and with return the lambda result

Comparisons

FunctionReceiver nameReturnsBest forExample intent
letitlambda resultnull checks, transformationsuse this value and produce something
runthislambda resultcompute using object contextcalculate something from this object
applythisobject itselfconfigurationset up this object
alsoitobject itselfside effectslog or inspect this object
withthislambda resultgrouped operations on existing objectwork with this object and return a result

apply vs also

Featureapplyalso
Inside blockthisit
Returnsobjectobject
Best useconfigurationside effects
val person = Person().apply {
    name = "Leo"
}

val samePerson = person.also {
    println(it)
}

let vs run

Featureletrun
Inside blockitthis
Returnslambda resultlambda result
Best usetransform a value, especially nullablecompute using object members directly
val length = "Kotlin".let { it.length }
val length2 = "Kotlin".run { length }

run vs with

Featurerunwith
Typeextension functionregular function
Called asobj.run {}with(obj) {}
Returnslambda resultlambda result
val a = car.run { "$brand $year" }
val b = with(car) { "$brand $year" }

Both are similar. The choice is often style and readability.

Take Quiz

Cheat Sheet

Quick rules

  • let = use it, return result
  • run = use this, return result
  • apply = use this, return object
  • also = use it, return object
  • with = use this, return result

Best use cases

  • let: null-safe calls and transformations
  • run: compute a result from an object
  • apply: configure an object
  • also: logging or side effects
  • with: group operations on an existing object

Return behavior

val x = "hello".let { it.length }   // Int
val y = "hello".run { length }      // Int
val z = Person().apply { name = "A" } // Person
val w = Person().also { println(it) }  // Person
val q = with("hello") { length }    // Int

Receiver style

// it
value.let { println(it) }
value.also { println(it) }

// this
value.run { println(this) }
value.apply { println(this) }
with(value) { println(this) }

Memory trick

  • apply: apply changes
  • also: also do this
  • let: let me use this value
  • run: run this code in object context
  • with: with this object, do the following

Edge cases

  • run and with often look interchangeable
  • apply and also both return the original object
  • let is commonly paired with ?.
  • Do not choose a scope function just because it is shorter; choose the one that makes intent obvious

FAQ

When should I use let in Kotlin?

Use let when you want to work with a value, especially a nullable one, and return a new result from it.

What is the difference between apply and also in Kotlin?

Both return the original object. apply is mainly for configuring the object, while also is mainly for side effects like logging.

What is the difference between run and with in Kotlin?

Both use this and return the lambda result. run is called on an object as an extension function, while with takes the object as an argument.

Why is let often used with nullable values?

Because value?.let { ... } only runs the block if value is not null.

Which scope function should I use to initialize an object?

Usually apply, because it lets you set properties and returns the configured object.

Which scope function is best for logging in a chain?

Usually also, because it returns the original object unchanged after the side effect.

Can I use scope functions everywhere?

You can, but you should not overuse them. If plain code is clearer, prefer plain code.

Are Kotlin scope functions faster than normal code?

They are mainly about readability and expressiveness. Performance differences are usually not the reason to choose them.

Take Quiz

Mini Project

Description

Create a small Kotlin program that builds a user profile, logs progress, safely handles an optional nickname, and generates a final summary string. This project demonstrates the natural role of apply, also, let, run, and with in one practical flow.

Goal

Build and configure a profile object, perform side effects, handle a nullable value, and produce summary text using the correct Kotlin scope functions.

Requirements

  • Create a Profile data class with mutable properties for name, age, city, and nickname.
  • Use apply to create and configure a Profile object.
  • Use also to print the object after configuration.
  • Use let to safely format the nickname only if it is not null.
  • Use run or with to generate a final summary string from the profile.
Take Quiz

Keep learning

Related questions

View more