Skip to main content
Scala beginner Lesson 2 of 10

Collections and Transformations

List, Vector, Map and Set — plus map, flatMap, groupBy and foldLeft, and why picking the wrong collection makes an O(1) operation O(n).

Scala’s collections are immutable by default: every transformation returns a new collection and leaves the original alone. That makes chains of operations safe to reason about, and it is why almost all Scala data code looks like a pipeline.

The four you need

@main def run(): Unit =
  val list   = List(1, 2, 3, 4, 5)          // linked list: fast prepend, slow index
  val vector = Vector(1, 2, 3, 4, 5)        // fast index, append, update
  val set    = Set("GB", "US", "GB", "NL")  // unique, unordered
  val map    = Map("GB" -> "United Kingdom", "US" -> "United States")

  println(list)
  println(vector)
  println(set)
  println(map)
  println(map("GB"))
  println(map.get("FR"))
List(1, 2, 3, 4, 5)
Vector(1, 2, 3, 4, 5)
Set(GB, US, NL)
Map(GB -> United Kingdom, US -> United States)
United Kingdom
None

Two details worth catching now. The Set dropped the duplicate GB silently — that is the point of a set, and a source of surprise when a count comes out lower than expected. And map.get returned None rather than throwing, while map("FR") would throw NoSuchElementException. Prefer get; lesson 4 is about what to do with the Option.

Which one, and why it matters

@main def run(): Unit =
  val n = 100_000
  val list = List.range(0, n)
  val vector = Vector.range(0, n)

  def time(label: String)(body: => Unit): Unit =
    val t0 = System.nanoTime()
    body
    println(f"$label%-24s ${(System.nanoTime() - t0) / 1e6}%.1f ms")

  time("list(50000)")   { (0 until 1000).foreach(_ => list(50000)) }
  time("vector(50000)") { (0 until 1000).foreach(_ => vector(50000)) }
  time("list prepend")  { (0 until 1000).foldLeft(list)((acc, i) => i :: acc) }
  time("vector append") { (0 until 1000).foldLeft(vector)((acc, i) => acc :+ i) }
list(50000)              412.8 ms
vector(50000)              0.4 ms
list prepend               0.1 ms
vector append              1.2 ms

A thousand indexed reads: 412ms on List, 0.4ms on Vector. List(i) walks i links every time. If you find yourself indexing a List in a loop, that is the bug.

The time method also shows a by-name parameter — body: => Unit is evaluated where it is used, not at the call site, which is how you write your own control structures.

Transforming

case class Order(id: Int, customerId: Int, country: String, status: String, amount: Double)

val orders = List(
  Order(1001, 1, "GB", "completed", 25.50),
  Order(1002, 2, "US", "completed", 12.00),
  Order(1003, 1, "GB", "returned",  40.00),
  Order(1004, 3, "GB", "completed",  8.75),
  Order(1005, 2, "US", "pending",   63.20),
)

@main def run(): Unit =
  println(orders.map(_.amount))
  println(orders.filter(_.status == "completed").map(_.id))
  println(orders.map(_.amount).sum)
  println(orders.count(_.country == "GB"))
  println(orders.exists(_.amount > 60))
  println(orders.forall(_.amount > 0))
  println(orders.sortBy(-_.amount).take(2).map(_.id))
List(25.5, 12.0, 40.0, 8.75, 63.2)
List(1001, 1002, 1004)
149.45
3
true
true
List(1005, 1003)

_ is a placeholder for the single argument, so _.amount is o => o.amount. It only works when the argument is used once — write the lambda out when it is not.

Grouping and aggregating

@main def run(): Unit =
  val byCountry = orders.groupBy(_.country)
  byCountry.foreach((country, os) => println(s"$country: ${os.map(_.id)}"))

  val revenue = orders
    .filter(_.status == "completed")
    .groupBy(_.country)
    .view.mapValues(_.map(_.amount).sum)
    .toMap
  println(revenue)

  val counts = orders.groupMapReduce(_.status)(_ => 1)(_ + _)
  println(counts)
GB: List(1001, 1003, 1004)
US: List(1002, 1005)
Map(GB -> 34.25, US -> 12.0)
Map(completed -> 3, returned -> 1, pending -> 1)

groupBy returns a Map[K, List[V]]. groupMapReduce does group, transform and combine in one pass — the idiomatic way to count occurrences, and it avoids building the intermediate lists that groupBy(...).mapValues(_.size) would.

view.mapValues(...).toMap rather than plain mapValues, because mapValues returns a lazy view whose function re-runs on every lookup — a classic performance surprise.

flatMap

case class Basket(orderId: Int, items: List[String])

val baskets = List(
  Basket(1001, List("SICP", "The Mythical Man-Month")),
  Basket(1002, List("Design Patterns")),
  Basket(1003, Nil),
)

@main def run(): Unit =
  println(baskets.map(_.items))
  println(baskets.flatMap(_.items))
  println(baskets.flatMap(b => b.items.map(title => (b.orderId, title))))
List(List(SICP, The Mythical Man-Month), List(Design Patterns), List())
List(SICP, The Mythical Man-Month, Design Patterns)
List((1001,SICP), (1001,The Mythical Man-Month), (1002,Design Patterns))

flatMap is the one-row-to-many operation. Note order 1003 disappeared entirely — an empty list contributes nothing, which is exactly how flatMap also acts as a filter.

Folding

@main def run(): Unit =
  println(orders.foldLeft(0.0)((acc, o) => acc + o.amount))

  val summary = orders.foldLeft(Map.empty[String, Double]) { (acc, o) =>
    acc.updated(o.country, acc.getOrElse(o.country, 0.0) + o.amount)
  }
  println(summary)

  println(List.empty[Int].sum)
  println(List.empty[Int].reduceOption(_ + _))
149.45
Map(GB -> 74.25, US -> 75.2)
0
None

foldLeft takes a starting value and combines left to right — it expresses any aggregation, including ones building a Map. The last two lines are the reason to prefer it to reduce: reduce on an empty collection throws, fold returns the seed, and reduceOption gives you a None instead.

Lazy chains

@main def run(): Unit =
  val nums = (1 to 1_000_000).toVector

  val strict = nums.map(_ * 2).filter(_ % 3 == 0).take(5)
  val lazily = nums.view.map(_ * 2).filter(_ % 3 == 0).take(5).toVector

  println(strict)
  println(lazily)
Vector(6, 12, 18, 24, 30)
Vector(6, 12, 18, 24, 30)

Identical results, very different work. The strict version builds a million-element vector, then a filtered one, then takes five. The view computes elements on demand and stops after five. Add .view when a chain is long and the collection is large; skip it otherwise, since laziness has its own overhead.

Mutable collections, when you need them

import scala.collection.mutable

@main def run(): Unit =
  val builder = mutable.ListBuffer.empty[Int]
  for i <- 1 to 5 do builder += i * i
  val result = builder.toList
  println(result)

  val counts = mutable.Map.empty[String, Int].withDefaultValue(0)
  for o <- orders do counts(o.country) += 1
  println(counts)
List(1, 4, 9, 16, 25)
Map(GB -> 3, US -> 2)

Legitimate inside a method that returns an immutable result — a local ListBuffer nobody else can see is not a shared-state problem. Returning a mutable collection from a public method is, because the caller can now change your object.

Practice

1. Total the completed orders per country.
val revenue = orders
  .filter(_.status == "completed")
  .groupMapReduce(_.country)(_.amount)(_ + _)
println(revenue)
Map(GB -> 34.25, US -> 12.0)

groupMapReduce in one pass instead of groupBy then mapValues — same result, no intermediate lists.

2. Index into a List in a loop and time it.
list(50000)     412.8 ms
vector(50000)     0.4 ms

A thousandfold difference from one collection choice. List is the right default for building by prepending and iterating; the moment you index, switch to Vector.

3. Use flatMap to expand orders into order lines.
println(baskets.flatMap(b => b.items.map(t => (b.orderId, t))))
List((1001,SICP), (1001,The Mythical Man-Month), (1002,Design Patterns))

Three baskets became three lines, and the empty basket vanished. When a row count drops after a flatMap, an empty inner collection is why.

4. Call reduce on an empty list.
println(List.empty[Int].reduce(_ + _))
Exception in thread "main" java.lang.UnsupportedOperationException: empty.reduceLeft

Then try fold(0)(_ + _) — it returns 0. Empty input is the normal case in data work, so prefer fold, sum, or reduceOption.

Next: case classes and pattern matching — modelling data and taking it apart.

Frequently Asked Questions

Should I use List or Vector in Scala?
`List` is a linked list — prepending and head access are constant time, indexing is linear. `Vector` gives effectively constant-time indexing, append and update. Use `List` when you build by prepending and consume front to back, `Vector` when you index or append.
What is the difference between map and flatMap?
`map` applies a function returning one value per element. `flatMap` applies a function returning a collection per element and concatenates the results, so it both transforms and flattens — which is how you expand one row into several, or drop rows by returning an empty collection.
Are Scala collections immutable by default?
Yes. `scala.collection.immutable` is what you get without an import, so a transformation returns a new collection and the original is untouched. Mutable versions exist under `scala.collection.mutable` and must be imported explicitly.
What does a view do in Scala?
`.view` makes a chain of transformations lazy, so intermediate collections are never built and elements are computed on demand. On a long chain over a large collection it avoids several full copies; on a short chain it is not worth the indirection.