Laziness
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Scala 3.10 (3.10.0, 2026-10-12) and every example was run with Scala CLI and Scala 3.10.0
on JDK 26 on 2026-10-15; the bytecode comparison also used Scala 3.3.8. lazy val, by-name parameters and LazyList work
unchanged on the 3.3 LTS and 3.9 LTS lines; only the generated code of lazy val differs across versions, as described below.
A lazy computation is postponed until its result is needed. Scala has three mechanisms with different meanings, and the pitfalls come from mixing them up:
-
lazy valevaluates its right-hand side at most once, on first use, and caches the result (call-by-need); -
a by-name parameter
x: => Tevaluates its argument every time it is used, possibly never (call-by-name); -
LazyListand collection views describe a sequence whose elements are computed on demand.
The basics of val and def are in Values, Variables & Literals
and Methods & Parameters; the lazy collection types have their own page,
Views, Iterators & LazyList.
The examples use the trailhead scenario of this section: a toolkit that plans hikes on a network of mountain trails.
lazy val
A val is evaluated once, when its enclosing object is constructed (or the block runs); a def is evaluated at every call.
A lazy val sits in between: it is evaluated once, the first time it is read.
//> using scala 3.10.0
def loadElevationTable(): Vector[Int] =
println("loading elevation table...")
Vector(1200, 1850, 2310)
class Planner:
println("Planner created")
val eager: Vector[Int] = { println("eager val"); Vector(1) }
lazy val elevations: Vector[Int] = loadElevationTable()
def highest: Int = elevations.max
@main def l1(): Unit =
val p = Planner()
println("after construction")
println(p.highest)
println(p.highest)
lazy val unused: Int = { println("never printed"); 1 }
println("end")
Planner created
eager val
after construction
loading elevation table...
2310
2310
end
The elevation table is loaded after construction, only once, and not at all if nobody reads it; the local lazy val unused
is never evaluated. Use lazy val for a value that is expensive, may not be needed, and does not change: a configuration
file, a parsed resource, an index. A lazy val can be local, a class or object member, or a trait member.
Initialisation order
Strict val`s are initialised in textual order, and reading one before its initialiser has run gives its default value
(`0, null, false), with no error. A lazy val is initialised when first read, so it can safely refer to values
defined later in the class. It also, deliberately, can hide an initialisation-order bug.
//> using scala 3.10.0
class Init:
val a = b + 1 // b is not initialised yet
val b = 10
lazy val c = d + 1 // d is read on first use of c
val d = 10
@main def l2(): Unit =
val i = Init()
println(i.a)
println(i.c)
1
11
a is 1 rather than 11 because b was still 0; the compiler does not stop this by default (the opt-in
safe-initialization checks are documented in the Reference). Declaring b before a, or making a lazy, fixes it.
Exceptions in the initialiser
If the initialiser throws, the exception propagates and the lazy val stays uninitialised: the next access runs the
initialiser again.
//> using scala 3.10.0
object Flaky:
var attempts = 0
lazy val value: Int =
attempts += 1
if attempts < 3 then throw RuntimeException(s"attempt $attempts failed")
42
@main def l2b(): Unit =
for _ <- 1 to 3 do
try println(Flaky.value)
catch case e: RuntimeException => println(e.getMessage)
println(Flaky.value)
println(Flaky.attempts)
attempt 1 failed
attempt 2 failed
42
42
3
The initialiser ran three times (two failures, one success) and the fourth read used the cached value. This retry behaviour is easy to forget when the initialiser has side effects.
Thread safety and the implementation
A lazy val is thread safe by default: if several threads read it at once, exactly one runs the initialiser and the
others wait for the result.
//> using scala 3.10.0
import java.util.concurrent.{CountDownLatch, Executors}
import java.util.concurrent.atomic.AtomicInteger
object Shared:
val runs = AtomicInteger(0)
lazy val value: Int =
runs.incrementAndGet()
Thread.sleep(100)
7
@main def l3(): Unit =
val pool = Executors.newFixedThreadPool(8)
val start = CountDownLatch(1)
val results = (1 to 8).map: _ =>
pool.submit(() =>
start.await()
Shared.value)
start.countDown()
println(results.map(_.get).toSet)
println(s"initializer ran ${Shared.runs.get} time(s)")
pool.shutdown()
Set(7)
initializer ran 1 time(s)
Eight threads were released together and the initialiser ran once. How this is achieved is a compiler detail with a version history:
-
Scala 2 synchronised on
this, which could deadlock when two classes' lazy vals were initialised from two threads in opposite order. -
Scala 3 before 3.8 (the 3.3.8 class was inspected for this page) uses a lock-free scheme with compare-and-swap operations on the field, written through
sun.misc.Unsafeviascala.runtime.LazyVals; the initialiser no longer holds the object’s monitor. -
Scala 3.8 and later does the same compare-and-swap through a
java.lang.invoke.VarHandle. The 3.8 release notes give the reason: from JDK 24sun.misc.Unsafeemits warnings, and on JDK 26 it throws, so the old code would not run. Libraries published with older Scala 3 versions can still trigger the problem; the 3.8 notes say tooling to migrate them is being worked on. The 3.3 LTS line has the new implementation behind the-Yfuture-lazy-valsflag from 3.3.8 (it needs at least JDK 9), per the same notes; this page did not run that flag.
The two generated classes differ visibly. For class B: lazy val x: Int = 1, the javap -p -c output of the 3.3.8 and
3.10.0 classes shows:
3.3.8: public static final long OFFSET$0;
private volatile java.lang.Object x$lzy1;
getstatic scala/runtime/LazyVals$.MODULE$ ... Method scala/runtime/LazyVals$.objCAS ...
3.10.0: private static final java.lang.invoke.VarHandle x$lzy1$lzyHandle;
private volatile java.lang.Object x$lzy1;
invokevirtual java/lang/invoke/VarHandle.compareAndSet ...
Source: Scala 3.8 release notes
An Int lazy val is stored in an Object field (x$lzy1) that holds either the value, or a marker meaning "evaluating"
or "waiting", plus a method x$lzyINIT1 with the initialiser; a program never sees these names, except in stack traces,
reflection and binary-compatibility tools. A cycle of lazy vals is not reported as an error: with lazy val a = b + 1; lazy val b = a + 1 in an object, reading a from one thread
did not finish within 120 seconds in a test run for this page, and the process had to be killed.
@threadUnsafe
When a lazy val is only ever used from one thread, the cost of the compare-and-swap protocol can be avoided with the
annotation scala.annotation.threadUnsafe:
//> using scala 3.10.0
import scala.annotation.threadUnsafe
class Cache:
@threadUnsafe lazy val big: Vector[Int] = { println("computing"); Vector.range(0, 5) }
lazy val safe: Int = 1
@main def l4(): Unit =
val c = Cache()
println(c.big)
println(c.big)
computing
Vector(0, 1, 2, 3, 4)
Vector(0, 1, 2, 3, 4)
The behaviour is the same on one thread. In the compiled class (javap -p on 3.10.0) the @threadUnsafe field
big$lzy1 is a plain, non-volatile private scala.collection.immutable.Vector guarded by a private boolean bigbitmap$1
flag, while safe gets a private volatile java.lang.Object safe$lzy1 with a VarHandle and an initialiser method;
the difference shows only when two threads race, in which case the
initialiser may run more than once and the result is not guaranteed to be consistent. Use it only for a measured
hot spot, in code that is single threaded by construction.
By-name parameters and call-by-need
A by-name parameter f: => T is evaluated each time it is used in the method body, and not at all if it is not used.
lazy val inside the method turns it into call-by-need: evaluate on first use, then cache.
//> using scala 3.10.0
def forecast(): String =
println(" (calling the forecast service)")
"clear"
def byValue(f: String): String = s"$f, $f"
def byName(f: => String): String = s"$f, $f"
def byNeed(f: => String): String =
lazy val once = f
s"$once, $once"
def skipped(flag: Boolean, f: => String): String = if flag then f else "no forecast"
@main def l5(): Unit =
println("by value"); println(byValue(forecast()))
println("by name"); println(byName(forecast()))
println("by need"); println(byNeed(forecast()))
println("skipped"); println(skipped(false, forecast()))
by value
(calling the forecast service)
clear, clear
by name
(calling the forecast service)
(calling the forecast service)
clear, clear
by need
(calling the forecast service)
clear, clear
skipped
no forecast
By-value (the default) evaluates the argument before the call, once. By-name evaluates it zero or more times. By-need
evaluates it zero or one time, which is usually what you want when the argument is expensive or has side effects. By-name
parameters are the basis of if-like methods, logging that skips building a message, Option.getOrElse and &&. They are
covered in more detail in Methods & Parameters and combine
with several parameter lists in Currying & Parameter Lists.
Views and LazyList
Collections are strict by default: map on a List computes every element at once. A view (xs.view) defers the
transformations until the result is forced, and a LazyList computes its elements on demand and remembers them.
//> using scala 3.10.0
@main def l6(): Unit =
def noisy(n: Int): Int = { println(s" computing $n"); n * n }
val strict = List(1, 2, 3, 4).map(noisy).take(2)
println(strict)
println("view:")
val viewed = List(1, 2, 3, 4).view.map(noisy).take(2)
println("(nothing computed yet)")
println(viewed.toList)
println("LazyList:")
val ll = LazyList.from(1).map(noisy)
println(ll.take(3).toList)
println(ll.take(3).toList) // cached: no recomputation
println(ll.head)
def primes(s: LazyList[Int] = LazyList.from(2)): LazyList[Int] =
s.head #:: primes(s.tail.filter(_ % s.head != 0))
println(primes().take(8).toList)
computing 1
computing 2
computing 3
computing 4
List(1, 4)
view:
(nothing computed yet)
computing 1
computing 2
List(1, 4)
LazyList:
computing 1
computing 2
computing 3
List(1, 4, 9)
List(1, 4, 9)
1
List(2, 3, 5, 7, 11, 13, 17, 19)
Source: Collections: Views and Scala 3 Book: Collections Types
The strict list computed all four squares and discarded two; the view computed two. The LazyList computed three
elements once and served the second take(3) from its cache. A view recomputes on every traversal; a LazyList
memoises. LazyList replaced Stream in Scala 2.13 (the #:: operator builds one lazily); the full treatment,
including iterators, is on Views, Iterators & LazyList.
Java’s Stream is lazy too but single use; Kotlin’s Sequence is lazy and re-traversable like a view; Python generators
are single use; JavaScript has lazy iterators. None of these has a lazy val equivalent built into the language (Kotlin has
by lazy, a delegate, and Swift lazy var).
Pitfalls
-
Lazy does not mean free. A
lazy valcosts an extra check on every read and a hidden field; use it for values that are expensive and possibly unused, not as a default for everyval. -
Side effects in an initialiser run once, at an unpredictable time, and run again after an exception.
-
A cycle of lazy vals hangs, rather than throwing, as noted above.
-
By-name parameters are re-evaluated. Passing
expensive()to a=> Tparameter that the method uses three times calls it three times; copy it into alazy valor avalif once is intended. -
@threadUnsaferemoves a guarantee; do not add it without a measurement.
Where the books are outdated
-
Scala 2 books describe the
this-synchronised lazy val and warn about its deadlocks; Scala 3 replaced that scheme, and since 3.8 the compare-and-swap implementation usesVarHandleinstead ofsun.misc.Unsafe. -
Material that says "use
Streamfor infinite sequences" refers to a type deprecated in Scala 2.13 in favour ofLazyList. -
Books written before Scala 3 do not mention
@threadUnsafe.