Scala Lazy Evaluation

Explore Scala's lazy evaluation and how it defers computation until needed. Learn through examples and see how it compares to Python's generators.

Focus: explore scala's lazy evaluation

Sponsored

As a Python developer, you've probably felt the pain of eagerly building an entire list or range, only to use a fraction of it — wasting memory and CPU for no good reason. Python's generators and itertools offer a partial escape hatch, but they're often bolted on after the fact. Scala takes a different, more principled approach: laziness is a first-class citizen of the language and its standard library. In this lesson, you'll explore Scala's lazy evaluation — how it defers computation until the last possible moment, how it compares to Python's generators, and when you should (and shouldn't) use it.

The problem this lesson solves

Eager evaluation is the default in most programming languages: when you write an expression like val nums = List(1, 2, 3).map(_ * 2), Scala immediately computes and stores the entire transformed list. That's fine for small data, but what about a million elements? Or a potentially infinite stream (think: all prime numbers, or sensor readings)? Eager evaluation would either exhaust memory or crash the program. Python solves this with generators using yield, but it's often seen as an afterthought — you must deliberately choose generator expressions or write custom iterator classes. Scala's lazy evaluation is woven into the language itself, offering elegant, composable abstractions like lazy val, view, and Stream/LazyList. This lesson bridges that gap, showing you how to think and code lazily in Scala, and why it matters for performance and responsiveness in real-world JVM applications.

Core concept / mental model

Think of evaluation like cooking a meal. Eager evaluation is like pre-cooking every dish on the menu, even if your customers only order one item — wasteful. Lazy evaluation is like a chef who waits until an order arrives before starting to prepare the dish — each ingredient is processed only when needed. In programming, laziness means deferring computation until the result is actually required, and potentially never doing it at all if the result is never used.

Scala offers several tools for laziness, each with a slightly different mental model:

  • lazy val: A single-value lazy binding — computed at most once, on first access. Great for expensive initializations (e.g., loading a config file or database connection).
  • view: A lazy transformation on a collection — operations like map, filter are applied on-demand as you iterate. This avoids building intermediate collections.
  • LazyList (formerly Stream): A lazily evaluated linked list that can represent infinite sequences as long as you only consume what you need.

Think of LazyList as Python's itertools.count() on steroids: it's a sequence that computes its next element only when you ask for it, but it also remembers past elements if you keep a reference (memoization).

How it works step by step

To master Scala's lazy evaluation, follow this mental progression:

  1. Identify eager hotspots — Look for chains of map, filter, flatMap, etc., that build intermediate collections. If the collection is large or infinite, that's a smell.
  2. Choose your lazy tool — For a single expensive value, use lazy val. For transformations on finite collections, use .view. For infinite or streaming sequences, use LazyList.
  3. Convert or construct — Turn an existing collection into a view with .view, or build a LazyList from a function: LazyList.from(1) or LazyList.iterate(start)(f).
  4. Transform and consume — Apply transformations like map and filter on the lazy structure — they become lazy as well. Then consume with take, foreach, toList, etc. Only the consumed elements are evaluated.
  5. Exploit short-circuiting — Use take(n) to grab a finite number of elements from an infinite sequence. Only n elements are computed.

Key differences from Python

Python Scala Why it matters
generator / yield LazyList Both defer computation, but Scala's is stricter and composable
itertools.islice .take(n) Similar, but Scala's is a built-in method
map, filter return lists .view makes them lazy Scala lets you make transform chains lazy without changing syntax
No built-in memoization LazyList memoizes Avoids re-computation on repeated access

But beware: laziness is not a silver bullet. It adds overhead (each access is a function call), and it can delay errors until use time rather than definition time — which can surprise you.

Hands-on walkthrough

Let's see this in action with runnable examples.

Example 1: lazy val for expensive initialization

object LazyValExample {
  // Simulate an expensive computation
  def expensive: String = {
    println("Computing...")
    "result"
  }

  def main(args: Array[String]): Unit = {
    lazy val cached = expensive
    println("Before access")
    println(cached)  // computes here
    println(cached)  // uses cached value
  }
}

Expected output:

Before access
Computing...
result
result

Notice Computing... prints only once — the lazy val is evaluated on first use and then memoized.

Example 2: view to avoid intermediate collections

object ViewExample {
  def main(args: Array[String]): Unit = {
    val nums = (1 to 1000000).toList

    // Eager: builds two full intermediate lists
    val eager = nums.map(_ * 2).filter(_ > 10).take(5)

    // Lazy: only computes what's needed
    val lazyResult = nums.view.map(_ * 2).filter(_ > 10).take(5).toList

    println(eager)
    println(lazyResult)
  }
}

Expected output:

List(12, 14, 16, 18, 20)
List(12, 14, 16, 18, 20)

Here, .view defers the map and filter until take(5) is called, avoiding the creation of two million-element intermediate lists.

Example 3: LazyList for infinite sequences

object LazyListExample {
  def main(args: Array[String]): Unit = {
    // Infinite sequence of natural numbers
    val naturals: LazyList[Int] = LazyList.from(1)

    // Take first 5 even numbers
    val evens = naturals.filter(_ % 2 == 0).take(5).toList
    println(evens)

    // Fibonacci as a lazy stream
    lazy val fib: LazyList[Int] = 0 #:: 1 #:: fib.zip(fib.tail).map { case (a, b) => a + b }
    println(fib.take(10).toList)
  }
}

Expected output:

List(2, 4, 6, 8, 10)
List(0, 1, 1, 2, 3, 5, 8, 13, 21, 34)

The #:: operator constructs a lazy stream, and the Fibonacci definition is elegantly self-referential — it would be impossible eagerly because it would recurse infinitely.

Compare options / when to choose what

Scala's laziness isn't a single tool — it's a spectrum. Here's when to reach for each:

Use case lazy val .view LazyList
Expensive single values (config, DB) ✅ Best
Chained transforms on finite data ✅ Best
Infinite sequences / streaming data ✅ Best
Memory footprint reduction ✅ Helps ✅ Helps ✅ Helps
Performance overhead Low Medium Higher (memoization)

Pro tip: If you only need a finite subset of an infinite sequence, LazyList + take is ideal. But if the sequence is finite and manageable, eager evaluation is often simpler and faster due to less overhead.

Variations to consider

  • Iterator: A mutable, one-pass alternative. It's lazy like LazyList but doesn't memoize, so you can't go back. Good for single-pass processing of large files.
  • Stream (legacy): The old name for LazyList in Scala 2.12 and earlier. In modern Scala (2.13+), use LazyList. The API is essentially the same, but Stream may not be as optimized.
  • Custom lazy classes: For complex laziness, you can combine lazy val and functions. Rarely needed, but powerful.

Troubleshooting & edge cases

Laziness can bite you in subtle ways. Here are common pitfalls:

1. StackOverflowError on long chains

If you build a deep chain of lazy operations (like a long LazyList), you might hit stack overflow because each #:: node adds a stack frame. Use .iterator or foreach to avoid deep recursion in recursive definitions.

2. Unintentional memoization

LazyList stores computed values. If you keep a reference to the head of a huge lazy list, memory usage grows as you consume. For single-pass use, Iterator is better.

3. Lazy values not initializing in the expected order

lazy val evaluates on first access, but if you access it from multiple threads, only one computes and others wait — but the order of initialization among multiple lazy vals can be surprising. Avoid circular dependencies between lazy vals.

4. Side effects in lazy code

If your map or filter has side effects (like println), they execute lazily — possibly at an unexpected time (or never). Keep lazy operations pure.

5. View conversion back to eager

Calling .toList on a view forces evaluation. If you forget it, you might get a View that recomputes every time you iterate — a performance trap.

What you learned & what's next

You've now explored Scala's lazy evaluation: you can explain the difference between eager and lazy evaluation, and you've applied it with lazy val, view, and LazyList in practical examples. You know when to use each tool and how to sidestep common pitfalls. This connects directly to your goal of becoming a fluent Scala developer — laziness is a cornerstone of functional programming and high-performance JVM apps.

As your next step, you'll dive into pattern matching and case classes, where those lazy collections can be destructured elegantly — think of pattern matching as the switch statement on steroids, working seamlessly with the lazy data structures you just mastered. Get ready to transform the way you handle data in Scala.

Practice recap

Open a Scala REPL and create a LazyList of all natural numbers, then use filter and take to print the first 10 multiples of 3. Next, define a lazy val that simulates a slow computation and observe when it runs. Finally, time a view-based transformation vs. an eager one on a list of 100,000 elements to see the memory impact — you'll be ready to apply laziness in your next project.

Common mistakes

  • Forgetting that LazyList memoizes — keeping a reference to a large lazy list can cause memory leaks.
  • Using .view but never converting back to a concrete collection (.toList), causing repeated recomputation on every access.
  • Placing side effects (println, file writes) inside lazy map/filter — they may execute at unexpected times or not at all.
  • Assuming all Scala collections are lazy by default — List, Vector, and Array are eager; only LazyList, view, and Iterator are lazy.

Variations

  1. Use Iterator for single-pass lazy traversal without memoization — ideal for reading large files or streams.
  2. The legacy Stream class in Scala 2.12 is replaced by LazyList in 2.13; migrate code accordingly.
  3. Consider third-party libraries like Cats Effect's fs2.Stream for purely functional streaming with backpressure, if you need more advanced control.

Real-world use cases

  • Processing an infinite sequence of sensor readings, taking only those above a threshold with take.
  • Lazy-loading expensive configuration or database connections using lazy val to defer initialization until first use.
  • Chaining map/filter on millions of rows in a data pipeline with .view to avoid creating multiple full-size intermediate collections.

Key takeaways

  • Eager evaluation computes immediately; lazy evaluation defers until needed and can avoid wasted work.
  • Use lazy val for expensive one-time values, .view for lazy transforms on finite collections, and LazyList for infinite sequences.
  • LazyList memoizes computed elements — balancing memory vs. recomputation.
  • Laziness adds overhead and can defer errors; use it deliberately for performance, not as a default.
  • Beware of side effects in lazy code — they execute at unpredictable times.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.