Every transformation on a strict Scala collection builds a new collection. orders.map(parse).filter(_.total > 500).take(3) on a million-element Vector parses a million records, allocates a million-element vector of results, filters it into another vector, and only then keeps three. A view changes the evaluation order: orders.view.map(parse).filter(_.total > 500).take(3).toList parses records one at a time and stops as soon as three have passed the filter.
That makes views look like free performance, and they are not. A view recomputes its whole pipeline every time you traverse it, holds a reference to its source, sees mutations of a mutable source, and can overflow the stack when nested deeply. This article explains the view model in the Scala 2.13 collections, which Scala 3 also uses, shows which operations keep which view type, works through a realistic example, and lists the failure modes that turn a view into a bug.
What a view is: a recipe pulled through iterators
A view is an Iterable whose elements are defined by a function of another collection rather than stored. Calling .view wraps the source; calling map, filter, take and similar methods on the result does not touch any element. Each call returns a new small object (internally View.Map, View.Filter and so on) that records the operation and points at the previous layer.
Work happens only when something asks for elements. Every view implements iterator by asking the layer below for its iterator and wrapping it: a map layer applies the function inside next(), a filter layer skips elements inside hasNext, a take layer counts. A terminal operation, such as toList, to(Vector), foldLeft, foreach, sum, find or size, calls iterator and pulls. Because elements flow through the pipeline one at a time, no intermediate collection is ever allocated and short-circuiting operations stop early.
The crucial difference from LazyList is that a view does not memoize. It is a recipe, not a cache. Each terminal operation builds a fresh iterator chain and runs every function again. The crucial difference from Iterator is that a view is reusable: you can traverse it as many times as you like, and each traversal starts from the beginning of the source.
The view family and which operations keep their type
In 2.13 the type of .view depends on the source, and the type determines what you can do cheaply afterwards:
| Source | .view returns | Extra abilities |
|---|---|---|
Iterable, Set | View[A] | Only traversal; no indexing, no known size in general |
List, LazyList and other Seq | SeqView[A] | apply(i), length, reverse, appended; indexing walks the source |
Vector, ArrayBuffer, Array, Range | IndexedSeqView[A] | Constant-time apply(i) and length (if the source has them), cheap slice and reverse |
Map | MapView[K, V] | get, mapValues, filterKeys without rebuilding the map |
Not every operation preserves the narrower type. On an IndexedSeqView, map, take, drop, slice and reverse return another IndexedSeqView, because the result's length and the position of element i can be computed without scanning. filter cannot know which source index holds the i-th surviving element without scanning, so it returns a plain View. After a filter, apply(i) is gone from the static type, and that is the library telling you the truth about cost.
This gives views a second use beyond fusing pipelines: cheap windows over indexed data. vec.view.slice(1000, 1100) copies nothing, arr.view.reverse reads the array backwards in place, and vec.view.map(f)(42) applies f to exactly one element.
Traversal count: the hidden cost
Because views recompute, the number of traversals matters as much as the operations. Consider this snippet:
var calls = 0
val parsed = lines.view.map { l => calls += 1; parse(l) }
val n = parsed.size // traversal 1: parses every line
val first = parsed.headOption // traversal 2: parses one line
val bad = parsed.count(_.isBad) // traversal 3: parses every line again
// calls == 2 * lines.size + 1Every terminal call is a full or partial pass that re-runs parse. When the function is cheap and pure, that may be fine. When it is expensive, allocates, logs or has any side effect, a view with more than one consumer is wrong. The fix is to materialize once with .to(Vector) (or toList, toArray) and share the strict result, or to compute everything in a single pass with foldLeft.
Materializing is also how you leave view-land on purpose. Scala 2.12 had force; in 2.13 it is deprecated in favour of .to(Factory) or the toX methods, which make the target type explicit. A useful rule: build the view, chain the transformations, and end the expression with exactly one terminal operation. If a view escapes into a val that several callers use, ask whether each of them should be paying for the full pipeline.
Worked example: the first 20 slow requests
A service keeps the last day of request records in a Vector[Request] and has a page that shows the first 20 slow requests from one tenant, enriched with a lookup that costs a few microseconds each. The strict version is:
def slowForTenant(reqs: Vector[Request], tenant: String): List[Row] =
reqs
.filter(_.tenant == tenant) // allocates a Vector of this tenant's requests
.map(enrich) // enriches all of them
.filter(_.latencyMs > 1000) // allocates another Vector
.take(20)
.toListWith 2 million records and a tenant owning 200,000 of them, this allocates two intermediate vectors and calls enrich 200,000 times to keep 20 rows. The view version:
def slowForTenant(reqs: Vector[Request], tenant: String): List[Row] =
reqs.view
.filter(r => r.tenant == tenant && r.latencyMs > 1000) // filter on raw fields first
.map(enrich) // only survivors are enriched
.take(20)
.toList // single terminal operationThree changes matter. The view removes the intermediate vectors. Moving the latency test before enrich (it only needs a raw field) means enrichment runs on survivors only. And take(20) before toList stops the scan after the twentieth match, so if slow requests are common near the start, most of the vector is never read. The result is a strict List, so callers can traverse it as often as they like without re-running anything.
Paging the same data shows the indexed side. For a table that renders rows 4,000 to 4,050 of an already-sorted Vector[Row], rows.view.slice(4000, 4050).map(render).toList renders 50 rows and copies none of the other 1,999,950. If the slice were taken after a filter, it would be a plain View and slicing would have to walk from the start, which is the moment to store a filtered strict copy instead.
Views over mutable collections
A view stores a reference to its source, not a snapshot. For an immutable source that is invisible. For a mutable one it is the whole story:
import scala.collection.mutable.ArrayBuffer
val buf = ArrayBuffer(1, 2, 3)
val tens = buf.view.map(_ * 10)
buf += 4
tens.toList // List(10, 20, 30, 40): the view reads the buffer at traversal time
buf.clear()
tens.toList // List()That is sometimes useful, for a live read-only window onto a buffer that one component owns. More often it is a bug: a view handed to another thread or stored in a cache observes later writes, and a mutation during traversal gives undefined results or an exception depending on the collection and version. Treat a view over a mutable collection as borrowing it; do not let the view outlive the code that owns the buffer.
The reference also keeps the source alive. hugeArray.view.slice(0, 10) stored in a long-lived field retains the whole array for the garbage collector, just as a substring once retained its parent string on older JVMs. If you want ten elements to outlive the array, copy them with toVector.
Maps: mapValues, filterKeys and MapView
The best-known view accident happened to maps. In Scala 2.12, Map.mapValues(f) and filterKeys(p) returned a lazy wrapper rather than a new map, which few readers expected from the signature. Every get re-ran f, so an expensive or side-effecting f ran on each lookup, and serialising the result could capture the whole underlying map and the closure.
Scala 2.13 made the laziness visible. Map.mapValues and filterKeys are deprecated, and the deprecation message points to map.view.mapValues(f), which returns a MapView whose type says it recomputes. To get a real map, write map.view.mapValues(f).toMap or simply map.map { case (k, v) => k -> f(v) }. If you are migrating 2.12 code and see the deprecation warnings, check every call site for a function that should not run per lookup.
What views cost, and when strict code wins
Views save allocation and short-circuit work, but each element pays for a chain of virtual calls through iterator layers, closures cannot always be inlined by the JIT across layers, and a View[Int] boxes every Int because the generic iterators work on objects. For a pipeline of one or two cheap operations over a small collection, the strict version is frequently as fast or faster, and simpler to reason about.
| Situation | Prefer |
|---|---|
| Several transformations, one terminal operation, large source | View |
Early exit: find, exists, take, headOption | View |
Window over indexed data: slice, reverse, single apply | IndexedSeqView |
| Result read more than once, or function is expensive | Strict collection, materialized once |
| Need memoization of an infinite or expensive sequence | LazyList |
| Single pass over a resource (file, socket) | Iterator inside Using |
| Hot numeric loop over primitives | Array with a while loop |
Measure with JMH rather than intuition when it matters. The collections performance notes cover allocation and boxing in more detail, and the iterators guide covers the single-pass alternative.
Failure modes
- Double evaluation. A view stored in a
valand consumed twice runs every function twice. Symptom: duplicated log lines, doubled metrics, or a lookup service seeing twice the traffic. Fix: materialize once. - Deferred exceptions.
xs.view.map(parse)does not throw at the line that builds it. The exception surfaces wherever the view is first traversed, possibly in another module, with a stack trace that points attoList. - Stack overflow from nesting. Building a view in a loop, such as
(1 to 50000).foldLeft(xs.view)((v, _) => v.map(_ + 1)), creates tens of thousands of layers. Traversal recurses through every layer for each element and throwsStackOverflowError. Materialize periodically, or compose the functions first and map once. - Captured mutable state. A view over an
ArrayBufferor a closure reading avarsees values at traversal time, not at construction time. - Retained memory. A small slice view pins its whole source.
- Laziness inside effects. Returning a view from a transaction, a
Usingblock or an effect means the work runs after the resource is closed. Return a strict collection across such boundaries. - Unclear
toString. Printing a view shows a placeholder likeSeqView(<not computed>)rather than elements, which confuses debugging; calltoListin the debugger expression instead.
What to do next
- Search your code for
.viewand for 2.12-eramapValuesandfilterKeys; for each, count how many times the result is traversed. - Rewrite multi-consumer views to materialize once with
.to(Vector)or a singlefoldLeft. - In long pipelines over large collections, add
.viewat the start and exactly one terminal operation at the end; move cheap filters before expensive maps. - Use
IndexedSeqViewfor windows and paging over vectors and arrays instead of copying slices. - Never return a view across a resource, transaction or thread boundary; return a strict collection.
- Benchmark any hot path with JMH before and after, including a strict baseline.
- Read the LazyList guide for memoized laziness, lazy evaluation in depth for by-name parameters and lazy vals, and the collections overview for the hierarchy views sit in.