Technology  /  Scala

๐Ÿ”ด Scala 21 guides ยท updated 2026

The JVM's hybrid OOP/functional language โ€” immutability, pattern matching, and the type system that powers Spark and Akka, from first principles to production.

Scala Collections: List, Vector, Set and Map Explained

Reaching for List by habit for everything is one of the most common early Scala mistakes โ€” not because List is bad, but because its performance characteristics are specific, and using it for the wrong access pattern turns an O(1) operation youโ€™d expect into an O(n) one silently. This guide covers the actual collection hierarchy, how immutable collections avoid the naive โ€œcopy everythingโ€ cost through structural sharing, and a concrete decision guide for which collection actually fits your access pattern.


The Immutable-by-Default Hierarchy

val list: List[Int] = List(1, 2, 3)
val vector: Vector[Int] = Vector(1, 2, 3)
val set: Set[Int] = Set(1, 2, 3)
val map: Map[String, Int] = Map("a" -> 1, "b" -> 2)

As covered in the previous guide, all four of these are immutable by default with no special import โ€” Scalaโ€™s Predef automatically brings the immutable versions into scope, and reaching for a mutable variant requires an explicit import scala.collection.mutable._.

Seq โ€” ordered collections

List โ€” linked list, fast prepend, slow random access

Vector โ€” tree-based, fast everything, the general-purpose default

ArraySeq/Array โ€” fixed-size, fast index, JVM array under the hood

Set โ€” unique elements, no order guarantee

Map โ€” key-value pairs


List โ€” A Linked List, Not an Array

val numbers = List(1, 2, 3)
val prepended = 0 :: numbers // List(0, 1, 2, 3) โ€” O(1), extremely fast
val appended = numbers :+ 4 // List(1, 2, 3, 4) โ€” O(n), must traverse the entire list

List in Scala is a genuine singly-linked list, not an array โ€” each element is a node pointing to the rest of the list. This is why prepending (::, pronounced โ€œconsโ€) is O(1): it just wraps a new node pointing at the existing list, with zero copying of the existing structure. Appending (:+) is O(n): thereโ€™s no way to add to the end of a linked list without walking every node to find it first.

numbers(0) // O(1) โ€” the head is directly accessible
numbers(2) // O(n) โ€” must walk from the head, 2 nodes deep, to reach index 2

Random access by index is also O(n) for exactly the same structural reason โ€” thereโ€™s no direct memory offset to jump to, only a chain of pointers to follow one at a time. This is the single most important fact about List: itโ€™s fast specifically at operations touching the front (prepend, read the head, pattern-match into head/tail), and slow at anything touching the middle or end.

Structural sharing โ€” why prepending is cheap without copying

original list

1

2

3

Nil

0

0 :: numbers doesnโ€™t copy [1, 2, 3] at all โ€” it creates exactly one new node (0) pointing at the existing, unchanged list [1, 2, 3]. Both the original numbers and the new prepended list can coexist, sharing the same tail structure in memory, because neither can ever be mutated โ€” this is only safe because the list is immutable; if [1, 2, 3] could change later, sharing it between two supposedly-independent lists would be a correctness bug. Structural sharing is the concrete mechanism that makes โ€œimmutable updateโ€ cheap rather than โ€œcopy the whole thing every time,โ€ and it applies throughout Scalaโ€™s immutable collection library, not just List.


Vector โ€” The General-Purpose Default

val v = Vector(1, 2, 3, 4, 5)
val updated = v.updated(2, 99) // Vector(1, 2, 99, 4, 5) โ€” efficient, not a full copy
val appended = v :+ 6 // efficient append, unlike List
val prepended = 0 +: v // efficient prepend too, unlike a plain array

Vector is implemented as a shallow, wide tree (specifically, a 32-way branching trie) rather than a linked list or a flat array โ€” this gives it effectively constant-time performance (technically O(logโ‚ƒโ‚‚ n), which is so close to constant for any realistic collection size that itโ€™s treated as O(1) in practice) for indexing, updating, appending, and prepending, at the cost of being marginally slower than List for the specific pure-prepend case List is optimized for, and marginally slower than a raw array for pure sequential iteration.

OperationListVector
Prepend (add to front)O(1) โ€” the fastest possibleO(1) effectively (small constant overhead)
Append (add to end)O(n)O(1) effectively
Random access by indexO(n)O(1) effectively
Update an element in the middleO(n)O(1) effectively

The practical rule: default to Vector unless you specifically know your access pattern is prepend-heavy and never needs random access or appending โ€” that specific, narrow pattern (common in recursive algorithms processing a list head-first, like the pattern-matching recursion shown in the functions guide) is where Listโ€™s specialization genuinely wins. For general-purpose โ€œa sequence of things Iโ€™ll read, update, and grow,โ€ Vector is the safer, more broadly performant default.


Set โ€” Unique Elements, No Guaranteed Order

val fruits = Set("apple", "banana", "cherry")
val withGrape = fruits + "grape" // new Set, with grape added
val withoutBanana = fruits - "banana" // new Set, banana removed
fruits.contains("apple") // true โ€” O(1) average, backed by a hash table internally
fruits ++ Set("date", "elderberry") // union
fruits & Set("banana", "fig") // intersection โ€” Set("banana")

Set guarantees no duplicate elements and (for the default HashSet implementation) offers average O(1) membership testing via internal hashing โ€” the same performance characteristic as a HashMap/HashSet in Java. contains being fast is Setโ€™s entire reason for existing over a List: checking โ€œis this element already presentโ€ in a List requires an O(n) linear scan, while a hash-based Set does it in near-constant time regardless of size.

// SortedSet for when order matters
import scala.collection.immutable.SortedSet
val sorted = SortedSet(3, 1, 4, 1, 5, 9) // SortedSet(1, 3, 4, 5, 9) โ€” sorted and deduplicated

Map โ€” Key-Value Associations

val ages = Map("Alice" -> 30, "Bob" -> 25)
ages("Alice") // 30 โ€” throws NoSuchElementException if the key is missing
ages.get("Alice") // Some(30) โ€” the safe alternative, covered in depth in the next guide
ages.getOrElse("Carol", 0) // 0 โ€” fallback for a missing key
val updated = ages + ("Carol" -> 35) // new Map with Carol added
val removed = ages - "Bob" // new Map with Bob removed
val merged = ages ++ Map("Dave" -> 40) // combine two maps

Mapโ€™s direct apply-style access (ages("Alice")) throws if the key is missing โ€” this is a real, common source of runtime errors for developers who forget the distinction, and the Option/Either/Try guide covers the idiomatic, safer alternatives (get, getOrElse) in depth, since avoiding exceptions for expected โ€œmight be missingโ€ cases is one of Scalaโ€™s core functional-programming idioms.

// Iterating and transforming a Map
val doubled = ages.map { case (name, age) => (name, age * 2) }
val namesOnly = ages.keys.toList
val agesOnly = ages.values.toList
val adults = ages.filter { case (_, age) => age >= 18 }

Mutable Collections โ€” When You Explicitly Opt In

import scala.collection.mutable
val buffer = mutable.ListBuffer[Int]()
buffer += 1
buffer += 2
buffer.append(3)
val finalList = buffer.toList // convert to an immutable List once done building
val mutableMap = mutable.Map("a" -> 1)
mutableMap("b") = 2 // in-place assignment
mutableMap += ("c" -> 3)

ListBuffer exists specifically for the case where youโ€™re incrementally building up a collection in a loop and want to avoid the (small, but real) cost of repeatedly producing new immutable collections one element at a time โ€” build it up mutably inside a tightly-scoped function, then convert to an immutable collection (.toList, .toVector) before returning it or exposing it further. This pattern โ€” mutable internally within a small, controlled scope; immutable at every boundary the rest of the program sees โ€” is a common, idiomatic compromise that gets performance where it matters without leaking mutability into the broader program.


Choosing a Collection โ€” A Decision Guide

NeedReach for
General-purpose sequence, unsure of exact access patternVector
Prepend-heavy, recursive head/tail processingList
Uniqueness + fast membership testingSet
Key-value lookupsMap
Sorted iteration order neededSortedSet / SortedMap
Building up a collection incrementally in a hot loopmutable.ListBuffer, converted to immutable at the end
Direct interop with Java APIs expecting java.util.List etc.scala.jdk.CollectionConverters for conversion, or Array for raw JVM array interop

LazyList โ€” Infinite and On-Demand Sequences

def naturals(n: Int): LazyList[Int] = n #:: naturals(n + 1)
val nats = naturals(1)
nats.take(5).toList // List(1, 2, 3, 4, 5) โ€” only the first 5 elements are ever actually computed

LazyList computes elements only as theyโ€™re actually accessed, and caches each computed element for reuse โ€” this is what makes defining a genuinely infinite sequence (naturals, which would recurse forever if eagerly evaluated) both possible and safe, since .take(5) only forces the first five elements to actually be computed. This is useful for generating sequences whose full extent either isnโ€™t known upfront or would be wasteful to compute entirely (searching for the first N results matching a condition, for instance, without knowing in advance how far into the sequence that search needs to go).

Common Pitfalls

SymptomLikely cause
A loop appending to a List is surprisingly slow:+ on List is O(n) per call โ€” an O(nยฒ) loop overall; use Vector, prepend with :: and reverse once, or a mutable.ListBuffer
ages("key") throws unexpectedlyMapโ€™s direct apply throws on a missing key โ€” use .get or .getOrElse instead
Two Sets with โ€œthe same elementsโ€ donโ€™t behave as expected in an iteration order-dependent testSet makes no ordering guarantee โ€” use SortedSet if order matters, or avoid relying on iteration order for a plain Set
Converting a large mutable buffer to immutable seems to copy needlesslyCalling .toList/.toVector on a ListBuffer is a deliberate one-time conversion cost โ€” this is expected and is the correct point to pay it, not a bug
Recursive processing of a Vector head-first is slower than expectedVectorโ€™s head/tail-style deconstruction isnโ€™t as cheap as Listโ€™s โ€” for genuinely prepend/head-heavy recursive algorithms, List remains the better fit

Frequently Asked Questions

Why does Scala default to Vector being recommended over List for general use, when List is more commonly seen in tutorials? Tutorials often reach for List first because pattern matching on head :: tail is a clean, illustrative way to teach recursion โ€” but for real applications with unpredictable access patterns (random reads, appends, updates), Vectorโ€™s more uniform performance profile is the safer default in practice.

Is Array ever the right choice in idiomatic Scala? Yes โ€” for raw performance-critical code, tight interop with Java APIs expecting arrays, or fixed-size numeric buffers, Array (a mutable, JVM-native array) is appropriate. Itโ€™s used more sparingly than List/Vector in typical application code specifically because itโ€™s mutable and doesnโ€™t fit Scalaโ€™s immutable-by-default idiom as naturally.

Does structural sharing mean immutable collections use less memory than Iโ€™d expect? Generally yes for related collections that share common structure (like a list and its prepended variant) โ€” but each entirely independent immutable collection still needs its own full structure; sharing only happens between collections derived from one another, not between unrelated ones.

Why does ages("Alice") throw instead of returning None directly? apply on Map mirrors direct indexing semantics (like list(0) throwing for an out-of-bounds index) โ€” for the explicitly-safe alternative, get (returning Option[V]) is the idiomatic choice, covered fully in the next guide.

When should I reach for a mutable collection instead of an immutable one? Almost exclusively for tightly-scoped, performance-sensitive local code building up a result incrementally โ€” converting to an immutable collection before that result is exposed to the rest of the program. Reaching for mutable collections as a general default undermines the safety and reasoning benefits immutability provides throughout the rest of your codebase.


Whatโ€™s Next

You now know which collection fits which access pattern, and why immutable collections avoid the naive performance cost their immutability might suggest. Next: Collection Operations, covering map, filter, fold, and reduce โ€” the transformation vocabulary youโ€™ll use on every one of these collection types daily.