lazy Delegated Properties
In Kotlin, a lazy delegated property is a property whose value is not computed until the first time it is actually read — after that, the computed value is cached, and every later read returns the cached result instantly. You declare one with the by lazy { ... } syntax, which hands off the property’s storage and initialization logic to a small helper object called a delegate. This is invaluable for expensive setup work — parsing a file, opening a connection, building a large data structure — that a class might never actually need, or that should only ever happen once no matter how many times the property is touched.
Overview: How lazy Delegation Works
Kotlin lets a property’s getter (and setter) be handled by another object instead of a plain backing field, using the by keyword. This is called property delegation. lazy(...) is a standard-library function (kotlin.lazy) that takes a no-argument lambda and returns an instance of Lazy<T>, an interface exposing a value: T property and an isInitialized(): Boolean function.
When you write val x: String by lazy { computeIt() }, the compiler does not store x as a plain field. Instead it generates a hidden field holding the Lazy<String> delegate object, and rewrites every read of x into a call to the delegate’s getValue(thisRef, property) operator function, which in turn reads .value on the Lazy instance. The first time .value is read, the Lazy object invokes your lambda, stores the result internally, and marks itself initialized. Every subsequent read simply returns the stored result — the lambda never runs again.
Because initialization must be deferred and then remembered, by lazy only works on val properties. A Lazy<T> only implements the read side of the delegate contract (getValue); it has no setValue, so there is nothing to call if you try to assign into the property afterward. This is a compile-time restriction, not a style suggestion — var x by lazy { ... } simply fails to compile.
By default, lazy is thread-safe: it uses a lock (LazyThreadSafetyMode.SYNCHRONIZED) so that if two threads read the property at the same time before it is initialized, only one of them runs the initializer while the other waits for the result, rather than both racing to compute it. You can relax this if you know better:
| Mode | Behavior |
|---|---|
LazyThreadSafetyMode.SYNCHRONIZED |
Default. Uses a lock so only one thread ever runs the initializer; other threads block until the value is ready. Safe for shared, multi-threaded access. |
LazyThreadSafetyMode.PUBLICATION |
The initializer may run more than once if threads race, but every thread ends up seeing the same first-published result. No blocking, but possibly wasted work. |
LazyThreadSafetyMode.NONE |
No locking at all. Fastest option, but only safe when the property is guaranteed to be accessed from a single thread. |
Syntax
val propertyName: Type by lazy {
// any setup code or side effects
lastExpression // becomes the cached value
}
val propertyName: Type by lazy(mode) {
lastExpression
}
by— the delegation keyword; tells the compiler this property’s getter is forwarded to the object on the right.lazy { ... }— a call to the standard-library functionlazy, taking a no-argument lambda and returningLazy<Type>.- The lambda’s last expression — becomes the property’s cached value; it runs exactly once, on first access.
mode(optional) — aLazyThreadSafetyModecontrolling locking behavior; defaults toSYNCHRONIZED.Type— usually inferred from the lambda’s result, but can be declared explicitly.
Examples
Example 1: The initializer runs once
fun main() {
val message: String by lazy {
println("Computing the value...")
"Hello, Kotlin!"
}
println("Before accessing message")
println(message)
println(message)
}
Output:
Before accessing message
Computing the value...
Hello, Kotlin!
Hello, Kotlin!
Notice “Computing the value…” is printed only once, right before the first println(message) — not when the property is declared, and not again on the second read. The second access reuses the cached string.
Example 2: Deferring expensive class setup
class Config {
val settings: Map<String, String> by lazy {
println("Loading settings from disk...")
mapOf("theme" to "dark", "language" to "en")
}
}
fun main() {
val config = Config()
println("Config created")
println(config.settings["theme"])
println(config.settings["language"])
}
Output:
Config created
Loading settings from disk...
dark
en
Constructing Config() is cheap — the “loading” work does not happen until settings is actually read for the first time. If a Config instance were created but its settings never read, that work would never run at all.
Example 3: A realistic computed report with a relaxed thread-safety mode
class Report(private val rows: List<Int>) {
val summary: String by lazy(LazyThreadSafetyMode.NONE) {
val total = rows.sum()
val average = if (rows.isEmpty()) 0.0 else total.toDouble() / rows.size
"Total: $total, Average: $average"
}
}
fun main() {
val report = Report(listOf(10, 20, 30, 40))
println("Report created")
println(report.summary)
}
Output:
Report created
Total: 100, Average: 25.0
Here summary depends on the constructor property rows; the sum and average are only computed the first time summary is read. LazyThreadSafetyMode.NONE is used because a Report is assumed to stay on a single thread, avoiding the (small) overhead of the default lock.
How It Works Step by Step
- 1. The compiler sees
by lazy { ... }and generates a hidden field of typeLazy<T>, assigned at construction time (for a class property) or at the declaration point (for a local variable) to the result of callinglazy { ... }— this stores the lambda itself, not its result. - 2. The property’s getter is rewritten to something equivalent to
return delegateField.getValue(this, ::propertyName), which internally readsdelegateField.value. - 3. On the first read,
Lazy.valuesees no value has been computed yet, invokes your lambda, stores the result, flips an internal “initialized” flag, and returns the result. - 4. On every later read,
Lazy.valuereturns the stored result directly — the lambda body, and any side effects likeprintlninside it, never run again. - 5. Because the
Lazy<T>wrapper object is created immediately at construction (holding an uncalled lambda), constructing an object with a lazy property is always cheap even when the eventual computation is expensive — the expense is paid only on first access, or never paid at all if the property is never read.
// Illustrative only — roughly what "by lazy" expands to.
// Not meant to be pasted into a real file as-is.
class Example {
private val delegate = lazy { expensiveComputation() }
val value: String
get() = delegate.value
private fun expensiveComputation(): String = "computed"
}
Common Mistakes
Mistake 1: Trying to combine var with lazy
It’s tempting to think “lazy just means computed later” and reach for a mutable property. It won’t compile:
class Widget {
var name: String by lazy { "Default" } // compile error
}
The compiler rejects this because lazy() returns a Lazy<String>, and that type only provides getValue — there is no setValue to satisfy a var delegate’s contract. The fix is simply to use val, since the whole point of lazy is a value computed once and never reassigned:
class Widget(private val defaultName: String) {
val name: String by lazy { defaultName.uppercase() }
}
fun main() {
val widget = Widget("gadget")
println(widget.name)
}
Output:
GADGET
If a property genuinely needs to be reassigned later, lazy is the wrong tool entirely — use a plain var, or a custom delegate implementing ReadWriteProperty.
Mistake 2: Expecting the initializer to run on every access
Because the block passed to lazy looks like ordinary code, it’s easy to assume it re-runs each time the property is read:
class Counter {
private var callCount = 0
val next: Int by lazy {
callCount++
callCount
}
}
fun main() {
val counter = Counter()
println(counter.next)
println(counter.next)
println(counter.next)
}
Output:
1
1
1
A reader expecting 1, 2, 3 is surprised: next is computed once and then permanently cached, so callCount is only ever incremented a single time. If you actually want fresh computation on every call, don’t model it as a lazy property at all — use an ordinary function:
class Counter {
private var callCount = 0
fun next(): Int {
callCount++
return callCount
}
}
fun main() {
val counter = Counter()
println(counter.next())
println(counter.next())
println(counter.next())
}
Output:
1
2
3
Best Practices
- Reach for
by lazywhen a property is expensive to compute and might not always be needed, or must only be computed once regardless of how many times it’s read. - Keep the lambda passed to
lazyfree of side effects that the rest of your code depends on running repeatedly — it will only ever execute once. - Use
LazyThreadSafetyMode.NONEonly when you can guarantee single-threaded access; otherwise stick with the safe default (SYNCHRONIZED) rather than risking a subtle race. - Don’t use
lazyto paper over an object that must fail fast — if an initializer can throw, remember the exception now happens on first access instead of at construction, which can surprise callers. - Prefer
lazyover a manual “nullable backing field plus null check” pattern for one-time cached values; it’s shorter, safer, and clearly communicates intent. - Remember
valonly protects the reference, not deep immutability — if the lazily computed value is itself a mutable collection, its contents can still change after the fact even though the property can’t be reassigned.
Practice Exercises
- 1. Write a class
Greeting(private val name: String)with a lazy propertyfullGreeting: Stringthat prints"Building greeting..."the first time it’s accessed and then evaluates to"Hello, $name!". Confirm inmainthat the message only prints once across three reads. - 2. Write a class
Cachewith a lazy propertyexpensiveList: List<Int>that builds a list of the squares of 1 through 5 the first time it’s read. Read it twice and printisInitializedbehavior conceptually by printing the list both times to confirm identical output. - 3. Take the
Reportexample from this lesson and add a second lazy propertymaxValue: Intthat depends on the samerowslist. Predict, then verify, which lazy properties get initialized if you only ever readsummaryand never readmaxValue.
Summary
by lazy { ... }delegates avalproperty’s storage and initialization to aLazy<T>object from the standard library.- The initializer lambda runs exactly once, on first read; the result is cached and reused for every later read.
- Only
valworks withlazy(), becauseLazy<T>only implements the read side (getValue) of the delegation contract. LazyThreadSafetyModecontrols locking:SYNCHRONIZED(default, safe under contention),PUBLICATION(lock-free, may compute more than once), andNONE(fastest, single-thread only).- Constructing an object with lazy properties is always cheap; the real cost is paid only if and when the property is actually read.
- Use
lazyfor expensive, one-time, possibly-unneeded computations — not for values that must be recomputed on every access.
