1. Introduction
When you create an object, Kotlin honestly runs everything needed for initialization: the constructor, init, property values. And sometimes that’s great. But there are situations where a value may not be needed at all — or will be needed only in one scenario, and you don’t want to spend time computing it “just in case.” That’s where the idea of lazy initialization appears: “compute it only on the first access.”
The most everyday analogy: you don’t fry potatoes every day “for later” if you’re not sure you’ll even be home today. You start cooking when you’re definitely hungry. lazy is roughly the same idea, just without the smell in the whole apartment.
In Kotlin, there’s a standard mechanism for this: delegated properties: val x by lazy { ... }. It computes the value once on the first read, and then returns the already prepared result from the cache. This behavior is exactly what the documentation describes: the block is executed on first access and the result is stored; subsequent reads return the stored value.
2. Syntax val x by lazy {...} and the meaning of by
At first glance, the syntax looks a bit magical: why not just write val x = ...? The secret is that lazy is a property delegate. That is, property reads are “delegated” to a special object that knows how to compute the value and how to store it afterward.
The syntax is:
val name: Type by lazy {
// computation
}
And the key practical rules here are very down-to-earth.
lazy is applied to val, because the point is that the value is computed once and then “fixed.” The documentation describes it as “computed on first access and remembered.”
If you want “var and also lazy,” that’s a different design: you either need a computed property (get()), or an explicit cache with invalidation (those are more advanced topics about architecture and responsibility).
The block inside lazy { ... } is a lambda, meaning a piece of code that can be executed later. We’ve seen lambdas before, so for now treat it as a “deferred instruction.”
Minimal example:
class AppInfo {
val banner: String by lazy { "ExpenseTracker v0.1" }
}
fun main() {
val info = AppInfo()
println(info.banner) // ExpenseTracker v0.1
}
The funny part is that this example doesn’t really show the point of lazy at all (a string is cheap anyway). But it helps you get used to the syntax before we start lazily doing something truly useful.
3. When the lazy block runs and what gets cached
The most common beginner question sounds like: “OK, it’s lazy… but when exactly does it compute?” The answer is very specific: when you read the property for the first time. Not when the object is created, not at compile time, and not “when Kotlin feels like it.” Exactly on the first access like obj.x.
Here’s a short demo where we intentionally add println to see the moment of computation:
class TokenProvider {
val token: String by lazy {
println("computing token...")
"T-123"
}
}
fun main() {
val p = TokenProvider()
println("before first read")
println(p.token) // computing token... T-123
println(p.token) // T-123
}
Notice the behavior: the message "computing token..." appears only once, and the second println(p.token) doesn’t run the block anymore because the value is cached. It’s like “brew coffee once — then drink from the mug,” except the coffee never runs out (which is already a bit of fantasy).
Formally, that’s the contract of lazy: the first call executes the lambda and stores the result; subsequent calls return the stored value.
4. lazy, computed property, and lateinit: similar, but solve different problems
It’s very easy to mix up these three approaches because outwardly they’re all about “a value that isn’t just a normal field.” But their meaning is different, and choosing correctly can save you hours of debugging.
Let’s compare them in one table:
| Approach | What it looks like | When the value appears | Does it change over time | Typical goal |
|---|---|---|---|---|
| Regular stored property | |
immediately when the object is created | — no, — yes |
simple data |
| Computed property | |
every time you read it | depends on the data used | “computed view” without storage |
| lateinit var | |
later, via assignment | yes, it’s a |
non-null, but initialized after creation |
| val x by lazy { ... } | |
on first read | no (once and for good) | defer computation and cache it |
Now let’s show short examples to make it “feel real in your hands.”
Computed property: recalculated every time
Here we don’t cache — this isn’t “laziness,” it’s “dynamic computation”:
class CounterView(private var x: Int) {
val doubled: Int
get() = x * 2
fun inc() { x++ }
}
fun main() {
val v = CounterView(10)
println(v.doubled) // 20
v.inc()
println(v.doubled) // 22
}
lateinit: the value is assigned later, and that’s your responsibility
class Config {
lateinit var apiKey: String
}
fun main() {
val c = Config()
c.apiKey = "SECRET"
println(c.apiKey) // SECRET
}
If you forget to assign it, you’ll get a runtime error. And that’s normal: lateinit is the contract “initialize first, then use.”
lazy: computed once and remembered
class Greeting(private var name: String) {
val hello: String by lazy { "Hello, $name" }
}
fun main() {
val g = Greeting("Ann")
println(g.hello) // Hello, Ann
}
And here comes the subtlety: if name is changed later (it’s private here, but imagine it), hello will not update, because lazy caches the value.
5. lazy in the ExpenseTracker application
So that lazy doesn’t remain a “trick for the sake of a trick,” let’s continue developing a single learning application. Let it be a small console ExpenseTracker: we store expenses, can add them, and print simple reports.
Let’s start with a simple expense class where we protect invariants via require:
class Expense(
val title: String,
val cents: Int,
val category: String
) {
init {
require(title.isNotBlank()) { "title must not be blank" }
require(cents > 0) { "cents must be > 0" }
require(category.isNotBlank()) { "category must not be blank" }
}
}
Now let’s make a class that stores the list of expenses. Here we intentionally keep the list private and expose only safe methods (in the spirit of encapsulation).
class ExpenseBook {
private val items: MutableList<Expense> = mutableListOf()
fun add(expense: Expense) {
items.add(expense)
}
fun size(): Int = items.size
}
So far it’s boring, but now we’ll get a place for lazy.
Lazy help text for a CLI
In console applications, there’s usually a help command that prints “how to use it.” This is text that most often no one reads at all… but when they do, it should be neat. And this is a good candidate for lazy: don’t assemble a multi-line text in advance if the user didn’t request it.
class ConsoleHelp {
val text: String by lazy {
"""
Commands:
add <title> <cents> <category> — add an expense
list — show expenses
help — help
exit — exit
""".trimIndent()
}
}
fun main() {
val help = ConsoleHelp()
println("App started") // App started
println(help.text) // (help is printed here)
}
If the user never entered help, you don’t “pay” for building the string at all. Yes, it’s a micro-optimization, but it demonstrates the idea very well: lazy controls the moment of computation.
Lazy money formatter
A slightly more “grown-up” example: money formatting. We store amounts in cents (cents), but we want to display them nicely: "12.05".
Let’s make a separate formatting class:
class MoneyFormatter {
fun format(cents: Int): String {
val dollars = cents / 100
val rest = (cents % 100).toString().padStart(2, '0')
return "$dollars.$rest"
}
}
Now suppose the formatter is “not always needed.” For example, you want to print a report only in the list command. Then you can keep it as lazy inside the application class:
class ExpenseApp {
private val book = ExpenseBook()
private val money: MoneyFormatter by lazy {
println("MoneyFormatter created")
MoneyFormatter()
}
fun addTestData() {
book.add(Expense("Coffee", 350, "food"))
}
fun printCount() {
println("items: ${book.size()}") // items: 1
}
fun printPriceExample() {
println(money.format(350)) // MoneyFormatter created 3.50
}
}
Here you’ll see that MoneyFormatter is created only when you first call printPriceExample() and actually access money.
6. The main lazy pitfall: the cache becomes stale when things change
This is where lazy stops being a “cute feature” and becomes something you need to use consciously.
If a lazy value depends on mutable state, you risk getting “nicely cached wrongness.” And it will be stable, fast, and confident — like a person who memorized the wrong answer and now has no doubts.
Bad example: we want a “total expenses” value, but the expense list gets new items. If we cache the sum via lazy, it will be correct only for the moment when we first accessed it.
class ExpenseBookBad {
private val items: MutableList<Expense> = mutableListOf()
val totalCents: Int by lazy {
items.sumOf { it.cents }
}
fun add(expense: Expense) {
items.add(expense)
}
}
Problem scenario:
fun main() {
val b = ExpenseBookBad()
b.add(Expense("Coffee", 350, "food"))
println(b.totalCents) // 350
b.add(Expense("Sandwich", 650, "food"))
println(b.totalCents) // still 350 (and that’s already wrong)
}
lazy honestly does what it promised: “compute once and remember.” You just picked the wrong tool.
What’s the right approach? If a value should reflect the current state, it’s better to make it a computed property:
class ExpenseBook {
private val items: MutableList<Expense> = mutableListOf()
val totalCents: Int
get() = items.sumOf { it.cents }
fun add(expense: Expense) {
items.add(expense)
}
}
Now the sum is recalculated every time, but it’s always up to date.
A practical rule is: lazy is great for values that either don’t depend on changing state, or depend on something that doesn’t change after initialization (configuration, text templates, prepared “heavy” objects you don’t want to recreate).
7. lazy nuances: side effects and thread safety
Reading a property (in an ideal world) is perceived as a “safe operation” that doesn’t change state and doesn’t produce surprises. lazy hides computation inside the property read — convenient, but it requires discipline.
Side effects: why a “quiet getter” is a good habit
If inside lazy you do println, network requests, file writes, or “let’s create 10000 objects” — reading a property turns into a mini-quest. Sometimes that’s acceptable (for example, a debugging println in learning code), but in a real project it’s better to keep such things under control.
Look at this example:
class ReportBuilder {
val report: String by lazy {
println("Building report...") // side effect
"Report v1"
}
}
If someone just logs the object and accidentally touches report, your report suddenly starts building “by itself.” That’s why a good habit is: the lazy block should be as “pure” as possible — compute and return a value, without surprises. And if there are side effects anyway, they should at least be semantically expected (for example, initializing a heavy cache).
Thread safety: what matters without diving into details
Sometimes people ask: “Is lazy thread-safe?” Very briefly: by default, lazy is implemented so that the computation is performed once in a multi-threaded environment (i.e., synchronization is provided). The documentation describes this as the default behavior and mentions the modes you can select with the LazyThreadSafetyMode parameter.
But the important practical point for us right now is this: in a typical learning console application that runs in a single thread, you almost always have enough with the “default lazy.” The main thing is to understand the contract “once on first read.”
One more nuance: if the lazy block throws an exception, the value won’t be successfully cached, and on the next read attempt the property will try to compute again (because there was no “successful result”). This is useful to remember if inside lazy you do require(...) or something else that can fail: the failure happens not when creating the object, but on the first property read, i.e., later, and sometimes that’s unexpected.
8. Typical mistakes when working with by lazy
Mistake No. 1: trying to make a var with lazy.
Sometimes you want to write var x by lazy {...}, because “well, I want it to be lazy.” But the point of lazy is that the value is computed once and fixed. If you need a mutable value, that’s not a lazy task: most often a computed property (get() = ...) or a regular var with explicit update control will fit better.
Mistake No. 2: caching with lazy something that depends on changing data.
A classic trap is caching a sum, status, or report that should change when items are added to a list. lazy runs once, and then returns an old result. If a value must always be current, use a computed property or recompute in the methods that mutate the state.
Mistake No. 3: hiding heavy and/or “noisy” logic inside a property read.
When lazy does a lot of work, reading a property suddenly becomes an “operation with side effects.” This makes code harder to understand: a developer sees obj.x and doesn’t expect half a second of computation. It’s better to keep the lazy block small, and move the complex part into a function you call inside lazy (at least you can see the name of the operation).
Mistake No. 4: expecting lazy to “speed up the program always.”
lazy is not a universal optimization. It helps defer computation until the moment it’s actually needed, and avoid repeating it. But if the value is needed always and immediately, lazy can even add a small overhead. Use it as a design tool to control the moment of computation, not as a “magic performance button.”
Mistake No. 5: quietly tying lazy to external mutability.
If lazy uses variables that can change before the first access, the result will depend on “when exactly was it first read.” Sometimes that logic is fine, but more often it’s a source of strange bugs. Good style: make lazy depend either on immutable configuration or on data you consider stable by the time of first use.
GO TO FULL VERSION