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._.
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 fastval appended = numbers :+ 4 // List(1, 2, 3, 4) โ O(n), must traverse the entire listList 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 accessiblenumbers(2) // O(n) โ must walk from the head, 2 nodes deep, to reach index 2Random 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
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 copyval appended = v :+ 6 // efficient append, unlike Listval prepended = 0 +: v // efficient prepend too, unlike a plain arrayVector 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.
| Operation | List | Vector |
|---|---|---|
| Prepend (add to front) | O(1) โ the fastest possible | O(1) effectively (small constant overhead) |
| Append (add to end) | O(n) | O(1) effectively |
| Random access by index | O(n) | O(1) effectively |
| Update an element in the middle | O(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 addedval withoutBanana = fruits - "banana" // new Set, banana removed
fruits.contains("apple") // true โ O(1) average, backed by a hash table internallyfruits ++ Set("date", "elderberry") // unionfruits & 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 mattersimport scala.collection.immutable.SortedSetval sorted = SortedSet(3, 1, 4, 1, 5, 9) // SortedSet(1, 3, 4, 5, 9) โ sorted and deduplicatedMap โ Key-Value Associations
val ages = Map("Alice" -> 30, "Bob" -> 25)
ages("Alice") // 30 โ throws NoSuchElementException if the key is missingages.get("Alice") // Some(30) โ the safe alternative, covered in depth in the next guideages.getOrElse("Carol", 0) // 0 โ fallback for a missing key
val updated = ages + ("Carol" -> 35) // new Map with Carol addedval removed = ages - "Bob" // new Map with Bob removedval merged = ages ++ Map("Dave" -> 40) // combine two mapsMapโ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 Mapval doubled = ages.map { case (name, age) => (name, age * 2) }val namesOnly = ages.keys.toListval agesOnly = ages.values.toListval adults = ages.filter { case (_, age) => age >= 18 }Mutable Collections โ When You Explicitly Opt In
import scala.collection.mutable
val buffer = mutable.ListBuffer[Int]()buffer += 1buffer += 2buffer.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 assignmentmutableMap += ("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
| Need | Reach for |
|---|---|
| General-purpose sequence, unsure of exact access pattern | Vector |
| Prepend-heavy, recursive head/tail processing | List |
| Uniqueness + fast membership testing | Set |
| Key-value lookups | Map |
| Sorted iteration order needed | SortedSet / SortedMap |
| Building up a collection incrementally in a hot loop | mutable.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 computedLazyList 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
| Symptom | Likely 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 unexpectedly | Mapโ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 test | Set 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 needlessly | Calling .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 expected | Vectorโ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.