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.

Immutability and Pure Functions in Scala: Why They’re the Default

Every guide so far in this series has quietly defaulted to val over var, and to case classes with copy over mutable fields. This guide makes the reasoning explicit: what a pure function actually is, what referential transparency buys you concretely, and why a language built for concurrent, distributed systems (recall Scala’s role in Spark and Akka from the first guide) treats immutability as the default rather than an opt-in style choice.


What Makes a Function “Pure”

// Pure — same input always produces same output, no observable side effects
def add(a: Int, b: Int): Int = a + b
// Impure — depends on and/or modifies state outside the function
var total = 0
def addToTotal(x: Int): Unit = {
total += x // mutates external state — a side effect
}
// Impure — depends on something outside its arguments (current time)
def greetWithTime(name: String): String = {
s"Hello $name, it's ${java.time.LocalTime.now()}"
}

A function is pure if it satisfies two properties: its output depends only on its arguments (nothing external — no reading a global variable, no reading the system clock, no reading a file), and it produces no observable side effects (no mutating a variable outside its own scope, no printing, no writing to a database). add above is pure — call it with (2, 3) a million times, in any order, from any thread, and you always get 5, with nothing else in the program affected. addToTotal and greetWithTime are both impure for different reasons: one mutates shared state, the other depends on something beyond its own arguments.


Referential Transparency — The Practical Payoff

val a = add(2, 3)
val b = add(2, 3)
// Because add is pure, this expression:
val c = add(2, 3) + add(2, 3)
// is GUARANTEED equivalent to:
val d = a + b
// and also equivalent to:
val e = 5 + 5

An expression is referentially transparent if it can be replaced by its own result, anywhere in the program, without changing the program’s behavior — this is exactly what pure functions guarantee, and it’s not an abstract property; it’s the concrete reason a compiler (or a human reader) can safely reorder, cache, deduplicate, or parallelize calls to a pure function without needing to trace through the entire program first to make sure nothing depends on a specific execution order or count.

// Contrast: this is NOT safe to reorder, cache, or deduplicate
def impureAdd(a: Int, b: Int): Int = {
println(s"Computing $a + $b") // side effect — order and count of calls is now observable
a + b
}

With impureAdd, replacing two separate calls with one cached result would silently drop a println — the function’s observable behavior, not just its return value, depends on how many times and in what order it’s actually called. This is the concrete difference referential transparency draws a hard line around: pure functions can be reasoned about purely by their input/output relationship; impure ones require tracking their entire execution history.

Pure function

Same input → same output, always

Safe to cache results (memoization)

Safe to run in parallel, any order

Safe to test — no setup/teardown of external state needed


Why Immutability Matters More as Systems Get Concurrent

// The classic shared-mutable-state bug — this is genuinely dangerous with multiple threads
class Counter {
private var count = 0
def increment(): Unit = { count += 1 }
def value: Int = count
}
val counter = new Counter()
// If two threads call counter.increment() "simultaneously," the read-modify-write
// sequence inside += can interleave, and one increment can be silently lost

count += 1 is not atomic — it’s actually read, add one, then write back, three separate steps. If two threads execute this sequence concurrently without synchronization, both can read the same starting value, both increment it, and both write back the same result — one increment is silently lost, with no exception, no crash, just a wrong number that’s extremely hard to reproduce and debug. This exact class of bug is why “shared mutable state” is considered one of the hardest problems in concurrent programming, and it’s completely, structurally impossible with immutable data — there’s nothing to race on, because nothing ever changes after creation.

// The immutable alternative — no shared mutable state, no race condition possible
case class CounterState(count: Int) {
def increment: CounterState = copy(count = count + 1)
}
val state0 = CounterState(0)
val state1 = state0.increment
val state2 = state1.increment
// Each state is a distinct, unchanging value — nothing to race on, ever

This is the direct, practical reason Scala’s role in high-concurrency systems (Akka actors passing immutable messages, Spark’s immutable RDDs/DataFrames distributed across a cluster) leans so heavily on immutability: when data can never change after creation, there’s no possibility of two threads (or two machines, in Spark’s distributed case) observing it in an inconsistent, partially-updated state.


Immutable Data Doesn’t Mean No State Changes — It Means New Values Instead

// Modeling a growing shopping cart with immutable data
case class Cart(items: List[String])
def addItem(cart: Cart, item: String): Cart = cart.copy(items = item :: cart.items)
val cart0 = Cart(List())
val cart1 = addItem(cart0, "Widget")
val cart2 = addItem(cart1, "Gadget")
println(cart0) // Cart(List()) — unchanged, still empty
println(cart1) // Cart(List(Widget))
println(cart2) // Cart(List(Gadget, Widget))

A common early misconception: immutability doesn’t mean a program can’t represent change over time — it means each individual value, once created, never changes; change is represented by producing a new value and updating which value a val or variable currently refers to, not by mutating the old value in place. cart0, cart1, and cart2 are three genuinely distinct, permanently-fixed values — the “cart growing” story is told by the sequence of separate values, not by any single value’s internals changing.


Side Effects Aren’t Forbidden — They’re Isolated

// Pure core logic
def calculateTotal(items: List[Double]): Double = items.sum
def applyDiscount(total: Double, discountPercent: Double): Double =
total * (1 - discountPercent / 100)
// Side-effecting shell, kept separate and thin
def processOrder(items: List[Double], discountPercent: Double): Unit = {
val total = calculateTotal(items)
val finalTotal = applyDiscount(total, discountPercent)
println(s"Final total: $finalTotal") // the ONE place a side effect happens
saveToDatabase(finalTotal) // also isolated here, not buried in the pure logic
}

Scala’s approach to purity is pragmatic, not doctrinaire (consistent with the hybrid design covered in the first guide): the goal isn’t eliminating side effects entirely — every real program eventually needs to print something, write to a database, or make a network call. The goal is isolating side effects at the edges of a program, keeping the core logic (calculateTotal, applyDiscount) pure, testable, and reorderable, while confining the actually-impure operations to a thin, clearly-marked outer layer. This “functional core, imperative shell” pattern is one of the most practically valuable habits to build, and it’s achievable in Scala without needing to adopt a fully pure functional language’s stricter discipline.


Testing Pure Functions Is Simpler, Concretely

// Testing a pure function needs no setup, no mocks, no teardown
assert(calculateTotal(List(10.0, 20.0, 30.0)) == 60.0)
assert(applyDiscount(100.0, 10.0) == 90.0)
// Testing the impure processOrder would need mocking println and saveToDatabase,
// or capturing/inspecting side effects some other way — genuinely more work

This is a concrete, everyday payoff of the pure/impure split: a pure function’s test is just “call it, check the return value” — no database to spin up, no mock objects, no need to verify a side effect happened correctly. The testing guide later in this series builds directly on this principle: the more of your logic that’s pure, the less of your test suite needs mocking infrastructure at all.


Immutable Collections by Default

val numbers = List(1, 2, 3) // scala.collection.immutable.List — the DEFAULT, imported automatically
numbers :+ 4 // returns a NEW list; numbers itself is completely unchanged
println(numbers) // List(1, 2, 3) — original untouched
import scala.collection.mutable.ListBuffer
val mutableNumbers = ListBuffer(1, 2, 3) // must explicitly opt into mutability
mutableNumbers += 4 // mutates in place

Scala’s default collection imports point to the immutable package — List, Map, Set used without any special import are all immutable by default, and reaching for a mutable variant requires an explicit import scala.collection.mutable._. This default is a deliberate design signal: immutability is the “normal” choice, mutability is the opt-in exception you reach for only when you have a specific, deliberate reason (a genuinely hot loop building up a large collection where the copying cost of immutable operations would matter). The collections guide covers the full immutable/mutable collection hierarchy in depth.


Memoization — A Direct Benefit of Purity

import scala.collection.mutable
def memoize[A, B](f: A => B): A => B = {
val cache = mutable.Map.empty[A, B]
a => cache.getOrElseUpdate(a, f(a))
}
def slowSquare(n: Int): Int = {
Thread.sleep(1000) // simulate expensive work
n * n
}
val fastSquare = memoize(slowSquare)
fastSquare(5) // takes ~1 second the first time
fastSquare(5) // instant — cached result reused

Caching a function’s result by its input is only safe to do because slowSquare is pure — if it depended on anything beyond its argument, or produced side effects on each call, caching would silently change the program’s behavior (skipping a side effect on the cached second call, or returning a stale result if the external dependency changed). Memoization is a clean, mechanical illustration of exactly what referential transparency guarantees: the cache and the live function are provably interchangeable.

Frequently Asked Questions

Does “immutable” mean my program can never change any data at all? No — it means individual values don’t change after creation; your program’s overall state changes by producing new values and updating which value a name currently points to, as the shopping cart example showed. The program’s behavior over time can still be dynamic; the data itself just doesn’t mutate in place.

Is every function in idiomatic Scala expected to be pure? No — side effects (I/O, logging, database writes) are a normal, necessary part of real programs. The idiomatic goal is isolating them at the edges of your program rather than eliminating them, keeping the bulk of business logic pure and easily testable.

Why does immutability matter specifically for concurrency? Because race conditions require shared, mutable state that multiple threads can observe in an inconsistent, partially-updated form. Immutable data structurally cannot be observed mid-mutation, because there is no mutation — every thread sees either the old value or a new one, never something in between.

Doesn’t creating new copies of data constantly hurt performance? Less than intuition suggests — Scala’s immutable collections use structural sharing internally (a new List with one prepended element reuses the entire existing list’s internal structure rather than copying it), so many “immutable update” operations are far cheaper than naively copying the whole structure. The collections guide covers this mechanism directly.

When is mutability actually the right choice in Scala? Tight, self-contained loops building up a large result where the constant allocation of immutable intermediate values would be a measurable performance cost, and cases where a mutable local variable is genuinely clearer than a more convoluted immutable equivalent — Scala’s hybrid design fully permits this; the point is defaulting to immutability, not forbidding mutation entirely.


What’s Next

Immutability and purity aren’t abstract functional-programming trivia in Scala — they’re the practical foundation behind the language’s use in concurrent and distributed systems. Next: Scala Collections, covering List, Vector, Set, and Map in depth, including exactly how structural sharing makes immutable collections practical at scale.