Skip to content
← All insights
Scala language16 min read

Lazy vals and laziness in Scala: defer work without hiding it

How lazy val, def, call-by-name parameters, LazyList and effect suspension differ—and the initialization, memory, concurrency and debugging trade-offs behind deferred work.

Laziness answers when an expression should run

Scala normally evaluates a val initializer when execution reaches its definition. A lazy val delays that initializer until the value is first selected. If evaluation succeeds, the result is stored and later selections return the same value.

This combines two decisions: defer the work and memoise the successful result. A def also delays its body until called, but evaluates it on every call. A by-name parameter delays the supplied argument and can evaluate it every time the parameter is used.

Those differences affect side effects, failures, memory and performance. “Lazy” is not one execution model, so code should identify whether it needs delayed construction, repeated computation, cached computation or a suspended effect.

Compare val, lazy val and defscala 2.13
val eager: UUID = UUID.randomUUID()
// Evaluated when this definition is reached.

lazy val cached: UUID = UUID.randomUUID()
// Evaluated on first access, then retained.

def fresh: UUID = UUID.randomUUID()
// Evaluated on every call.

(cached, cached) // the same UUID twice
(fresh, fresh)   // two calls, normally two UUIDs

A lazy val memoises only successful initialization

The first access evaluates the initializer. If it completes normally, the result becomes the stored value. If it throws, no successful value exists to cache, so a later access may evaluate the initializer again.

That retry behaviour is dangerous when initialization performs a non-idempotent side effect. A failed lazy database write, file mutation or external request may have completed partly before throwing and can be repeated by the next caller.

Keep lazy initializers pure where possible. When resource acquisition or retry policy matters, use an explicit Resource, startup process or effectful memoisation mechanism whose failure and release semantics are visible.

A failure can make the initializer run againscala 2.13
var attempts = 0

lazy val configuration: Config = {
  attempts += 1
  if (attempts == 1) throw new RuntimeException("unavailable")
  Config.load()
}

Try(configuration) // Failure; attempts == 1
Try(configuration) // initializer runs again; attempts == 2

// This behaviour is much easier to reason about when the initializer
// does not perform externally visible writes.

Call-by-name delays work but does not cache it

A parameter declared as value: => A receives an unevaluated expression. The method evaluates that expression whenever it refers to value. Using the parameter twice normally runs the caller’s expression twice.

This is useful for control-flow APIs such as getOrElse, assertions and retry helpers that should avoid evaluating an alternative unless it is needed. It becomes surprising when the argument is expensive or effectful and the method body uses it more than once.

Introduce a local lazy val when the method needs at-most-once evaluation. That choice should be deliberate because it also retains the result until the method returns.

Cache a by-name argument when at-most-once is intendedscala 2.13
def duplicate[A](value: => A): (A, A) =
  (value, value)

def duplicateOnce[A](value: => A): (A, A) = {
  lazy val cached = value
  (cached, cached)
}

duplicate(UUID.randomUUID())     // evaluates twice
duplicateOnce(UUID.randomUUID()) // evaluates once

Use lazy val for an expensive stable derived value

A good lazy val candidate is expensive to construct, may never be needed and remains valid for the lifetime of its owner. A parsed lookup table, derived index or optional diagnostic representation can fit this shape.

The owner’s lifetime matters. Once initialized, the cached value remains reachable through that owner. A large value inside an application singleton can therefore remain in memory for the life of the process even if it was needed only once.

Measure before making ordinary calculations lazy. The initialization machinery and additional state have a cost, and def or eager val is clearer when the value is cheap or always used.

Delay a stable index until a caller needs itscala 2.13
final class CountryDirectory(rows: Vector[Country]) {
  lazy val byCode: Map[String, Country] =
    rows.iterator.map(country => country.code -> country).toMap

  def find(code: String): Option[Country] =
    byCode.get(code.toUpperCase)
}

// Building the directory does not build the index.
// The first find does, and later finds reuse it.

Initialization timing is part of observable behaviour

Moving an eager val to lazy val can change which request sees an error, which thread performs the work and whether application startup detects invalid configuration. The program may compile unchanged while its failure mode moves from deployment to live traffic.

Required configuration, database connectivity and other startup invariants often deserve eager validation. Failing during startup can be safer than allowing the application to report healthy and fail when a rarely used route first touches a lazy dependency.

Laziness is valuable when optional work really is optional. Do not use it to make an unhealthy application appear to start successfully. Record intentional lazy initialization so production latency and failures are not mysterious.

Concurrent first access can create contention

A successfully initialized lazy val must not expose a partially constructed value to competing callers. Scala’s implementation coordinates initialization, although the mechanism and generated code differ across compiler versions.

If initialization is slow, concurrent callers reaching the value for the first time can wait behind that work. In complex initialization graphs, lazy values that depend on one another across threads can contribute to deadlock or difficult re-entrancy behaviour.

Keep initializers short and avoid blocking I/O inside them. Warm an important value deliberately during startup when first-request latency is unacceptable, or model asynchronous initialization through the application effect rather than blocking whichever request arrives first.

  • Which thread can perform the first access?
  • How long can initialization take?
  • Can the initializer call back into the same object graph?
  • What do other requests observe while it runs?
  • Should readiness wait for this value?
  • How will initialization failure be logged and retried?

Laziness does not repair unsafe construction order

Class and trait initialization order can expose fields before a subclass has initialized them. Replacing an affected val with lazy val sometimes delays the read until values are ready, but it can also conceal a fragile inheritance design.

Prefer constructor parameters, final classes and explicit composition when one component requires another value. Scala compiler initialization warnings can expose risky access patterns and deserve investigation rather than automatic suppression.

Recursive lazy definitions are another warning sign. A value that reaches itself before finishing may recurse, fail or become entangled with initialization coordination. Express genuine recursive data through a type designed for it instead of relying on object initialization cycles.

Prefer an explicit construction dependencyscala 2.13
final class ReportFormatter(labels: Labels) {
  def heading: String = labels.reportHeading
}

val labels = Labels.load(configuration)
val formatter = new ReportFormatter(labels)

// The dependency and initialization order are visible.
// A lazy override is not needed to make inheritance order work.

LazyList and View defer collection work differently

LazyList evaluates elements as they are demanded and memoises evaluated elements. It can represent a sequence larger than memory or an unbounded sequence, but retaining a reference to its head can retain the already evaluated prefix.

A collection View defers transformations until the view is consumed and generally does not promise memoisation. Consuming the view again may repeat the transformations. Iterator is also lazy but stateful and normally single-use.

Choose from the consumption contract. Use a strict collection when the complete stable result is needed, a view to avoid intermediate collections in one transformation pipeline, an iterator for one-pass traversal and LazyList when a reusable memoised lazy sequence is genuinely useful.

Materialise only the finite result that is neededscala 2.13
val positiveSquares: List[Int] = LazyList
  .from(1)
  .map(value => value * value)
  .take(5)
  .toList

val normalised: Vector[String] = names
  .view
  .map(_.trim)
  .filter(_.nonEmpty)
  .toVector

// toList and toVector are the deliberate strict boundaries.

Effect suspension is not lazy-val memoisation

Cats Effect IO delays effectful work by describing it as a value for the runtime to interpret. Running the same IO more than once normally performs the work more than once. A lazy val containing IO delays construction of the description, not execution of the described effect.

Future behaves differently because constructing one normally schedules its body immediately. Wrapping a Future definition in lazy val delays when it is scheduled and then reuses the same Future result, combining deferred start with memoisation.

These distinctions matter for repositories, HTTP calls and background tasks. Choose the effect model first; do not use lazy val as an improvised replacement for Resource, caching, concurrency control or an application lifecycle.

Separate a deferred effect from a memoised resultscala / Cats Effect
val readClock: IO[Instant] = IO.realTimeInstant

val twice: IO[(Instant, Instant)] = for {
  first  <- readClock
  second <- readClock
} yield (first, second)

// The IO is a reusable description; it is not a cached Instant.

lazy val scheduled: Future[Account] = repository.find(id)
// The first access schedules one Future; later accesses reuse it.

Use laziness when the lifecycle is clear

Laziness can avoid unnecessary work, support large sequences and provide concise control-flow APIs. It can also move failures into request handling, retain memory, repeat by-name effects and make the first caller responsible for expensive initialization.

Review the complete lifecycle: when can the first access occur, should the result be cached, how long will it remain reachable, what happens after failure and which runtime owns any effect? If those answers are unclear, an eager val, explicit method, Resource or named cache is usually easier to operate.

The strongest use of lazy val is modest: one stable value, expensive enough to defer, safe to compute once and naturally owned for the lifetime of its enclosing object. Use other forms of laziness for the different contracts they actually provide.