Every Scala tutorial says the same thing in its first chapter: use val for values that do not change and var for variables that do. That sentence is true and nearly useless. It does not tell you why a val holding an ArrayBuffer can still corrupt data under concurrency, why a val inside a trait can be null when you read it, why a var captured by a closure behaves differently from one that is not, or when a var is the correct engineering choice rather than a code smell.
This article works from first principles. It separates the binding from the object, shows what the compiler generates for each kind of member, covers lazy val in Scala 2 and Scala 3, walks through the initialization-order failures that catch experienced developers, and ends with a worked refactor and a checklist you can apply in code review tomorrow.
Bindings, not boxes
A name in Scala is a binding: an arrow from the name to a value. val fixes the arrow at definition time. var lets you point the arrow somewhere else later with =. Neither keyword says anything about whether the thing at the end of the arrow can change. That is decided by the type of the value.
An Int, a String, a List or a case class with only val fields is immutable, so a val bound to one is a true constant. An ArrayBuffer, a java.util.HashMap or an Array is mutable, so a val bound to one gives you a fixed reference to something that can change underneath every reader. The reverse combination, a var holding an immutable List, is common and often fine: each assignment creates a new list, and anybody who read the old one still has a consistent snapshot.
Hold on to this model; everything else in this article follows from it. When a reviewer says 'make it a val', the real question is whether the whole reachable object graph is immutable. Scala's collections give you an immutable default in scala.collection.immutable precisely so that val can mean what people assume it means.
The syntax, including the corners
val limit = 10 // type inferred as Int; cannot be reassigned
var count = 0 // type inferred as Int; count = count + 1 is allowed
val name: String = "ada" // explicit type
var cache: Map[String, Int] = Map.empty // widen the type so later assignments fit
// limit = 11 // compile error: reassignment to val
// Pattern definitions bind several names at once
val (host, port) = ("db.internal", 5432)
val head :: tail = List(1, 2, 3): @unchecked // Scala 3 wants @unchecked for refutable patterns
// Class parameters
class A(x: Int) // plain parameter: no public accessor
class B(val x: Int) // public read-only accessor
class C(var x: Int) // public accessor and setter
case class D(x: Int) // case class parameters are vals by defaultTwo details matter. Inference takes the initializer's type, so var cache = Map.empty infers Map[Nothing, Nothing] and later assignments fail; give mutable bindings an explicit type. And a pattern definition like val head :: tail = xs throws MatchError if it does not match, which is why Scala 3 warns unless you add @unchecked.
A plain class parameter such as x in class A(x: Int) has no accessor; it becomes a private field only if a method uses it. Case classes make every parameter a val, which is why they work as immutable data carriers by default.
What the compiler generates
A val declared in a class compiles to a private field and a public getter method with the same name. A var compiles to a private field, a getter and a setter called name_=. When you write obj.hits = 5, the compiler rewrites it to obj.hits_=(5). That means you can later replace a public var with a getter and a custom setter that validates, logs or notifies, without changing a single caller:
class Account:
private var _balance: BigDecimal = 0
def balance: BigDecimal = _balance
def balance_=(v: BigDecimal): Unit =
require(v >= 0, s"negative balance $v")
_balance = v
val acct = Account()
acct.balance = 100 // calls balance_=(100)
acct.balance = -1 // IllegalArgumentExceptionThis is the uniform access principle: a caller cannot tell whether acct.balance is a field, a val or a def. It is also why overriding rules look the way they do. A val can override a parameterless def because a stable value satisfies the contract 'returns a value'. A def cannot override a val because callers were promised stability. An abstract var in a trait is simply an abstract getter and setter pair that the implementing class must provide.
Local vars and closures
A var declared inside a method lives in a JVM local slot, which is as cheap as storage gets. That changes when a lambda captures it. JVM lambdas can only capture effectively final values, so the Scala compiler moves the variable into a heap object, scala.runtime.IntRef for an Int or ObjectRef for a reference, and both the method and the lambda share that box.
def countMatches(xs: Seq[String], p: String => Boolean): Int =
var n = 0 // captured below, so compiled as an IntRef
xs.foreach(x => if p(x) then n += 1)
nThe boxing costs an allocation, which matters only in very hot code. More importantly, the variable now escapes the stack frame. If the lambda runs on another thread, as it does inside a Future or a parallel collection, you have a data race and the compiler does not warn you. Futures and execution contexts covers how callbacks are scheduled; never write a captured var from code that can run concurrently with other readers or writers.
lazy val: once, on first use
A lazy val is evaluated the first time it is read, then cached. It is useful for expensive values that may never be needed and for breaking some initialization-order cycles. It is not free. In Scala 2 the compiler adds a flag field, and the first access runs the initializer inside a synchronized block on the enclosing instance, so two lazy vals in the same object that depend on each other from different threads can deadlock. Scala 3.3 replaced that scheme with a new implementation that does not lock the whole instance, and Scala 3 offers @scala.annotation.threadUnsafe for lazy vals that will only ever be touched by one thread and should skip the synchronization entirely.
object Config:
lazy val settings: Map[String, String] =
println("loading") // runs once, on first access
loadFromDisk()
@scala.annotation.threadUnsafe // Scala 3 only: no synchronization
lazy val scratch = new StringBuilderTwo failure modes are worth knowing. If the initializer throws, the value is not cached and the next access runs it again, which can hammer a failing dependency. And a lazy val whose initializer reads another lazy val that reads the first one does not deadlock on a single thread; it is a cycle with no valid answer, and depending on the Scala version it ends in a stack overflow or a hang. Keep lazy initializers small, side-effect-free where possible and acyclic.
Initialization order: the null val
The most confusing val bug in Scala is reading a val before it has been assigned. Constructors run from the top of the inheritance chain down. If a trait's body uses an abstract val that a subclass defines, the trait's code runs before the subclass has assigned its field, and the read sees the JVM default: null, 0 or false.
trait Greeter:
val name: String
val greeting = s"Hello, $name" // runs during Greeter's initializer
class Ada extends Greeter:
val name = "Ada"
println(Ada().greeting) // prints "Hello, null"Fix it by making greeting a def or lazy val, or by making name a constructor parameter, which is assigned before any body code runs. Scala 3 also ships a safe-initialization checker, enabled with -Wsafe-init (earlier releases used -Ysafe-init), that reports many of these accesses at compile time. Turn it on in new builds; the warnings it produces are nearly always real bugs.
Concurrency: val is not a lock
On the JVM, a field that is final and assigned in the constructor gets a visibility guarantee: once a properly constructed object is published, other threads see the assigned value. That is part of why immutable objects are safe to share without locks, as the Java Memory Model article explains. A var gets no such guarantee. A thread can read a stale value indefinitely, and a read-modify-write like count += 1 is three steps that interleave with other threads and lose updates.
Your options, from weakest to strongest: @volatile var gives visibility for single reads and writes but not atomic updates, so it suits flags such as @volatile var running = true. java.util.concurrent.atomic.AtomicLong or AtomicReference give atomic compare-and-set. A lock or synchronized block protects compound invariants across several fields. In effect systems, a Ref from Cats Effect or ZIO wraps an atomic reference behind a purely functional API; ZIO STM goes further and composes updates across several references transactionally.
import java.util.concurrent.atomic.AtomicLong
final class Metrics:
private val requests = AtomicLong(0) // val binding to a thread-safe object
def hit(): Unit = requests.incrementAndGet()
def total: Long = requests.get()The shared state is a val bound to an object designed for concurrent mutation: the binding never changes, and the object manages its own consistency in one place instead of in every caller.
Worked example: from vars to a fold, and back
Here is a common shape of code in a log-processing job. It works, but the reader has to simulate the loop to understand the result, and the four vars can be updated inconsistently.
def summarize(lines: Iterator[String]): (Int, Int, Long, Option[String]) =
var total = 0
var errors = 0
var bytes = 0L
var firstError: Option[String] = None
for line <- lines do
total += 1
bytes += line.length
if line.contains("ERROR") then
errors += 1
if firstError.isEmpty then firstError = Some(line)
(total, errors, bytes, firstError)The functional version names the state as a case class and describes one step:
final case class Summary(total: Int, errors: Int, bytes: Long, firstError: Option[String]):
def add(line: String): Summary =
val isErr = line.contains("ERROR")
Summary(
total + 1,
if isErr then errors + 1 else errors,
bytes + line.length,
if isErr && firstError.isEmpty then Some(line) else firstError,
)
object Summary:
val empty = Summary(0, 0, 0L, None)
def summarize(lines: Iterator[String]): Summary =
lines.foldLeft(Summary.empty)(_ add _)The step function add is pure, so it is trivial to unit test, and summaries from different partitions can be merged, which is what a distributed engine needs. The cost is one small allocation per line: noise for a log job, but possibly significant for a numeric kernel over hundreds of millions of primitives. There, a while loop over local vars in one method is the right answer and a legitimate use of var: the mutation is confined to one stack frame, never captured or shared, so the function is still pure from outside. Measure before choosing it.
Failure modes and review heuristics
| Symptom | Usual cause | Fix |
|---|---|---|
| Value is null or 0 inside a trait | Abstract val read during superclass initialization | def, lazy val or constructor parameter; enable -Wsafe-init |
| Counts are too low under load | var += from several threads or callbacks | AtomicLong, a Ref, or confine to one thread |
| Flag change never seen by a worker loop | Plain var read in a tight loop | @volatile var or an AtomicBoolean |
| Data changes behind a 'constant' | val bound to a mutable collection that escaped | Immutable collection, or copy on the way out |
| Deadlock at startup in Scala 2 | Cyclic lazy vals initialized from two threads | Break the cycle; eager vals where possible |
| Assignments fail to compile | var type inferred too narrowly from its initializer | Declare the type explicitly |
In review, ask three questions. Does this var escape its method? If it is a field, what makes each thread's access safe? If it is a val, is everything reachable from it immutable?
What to do next
- Grep your codebase for
varand classify each hit as local, captured, private field or public field; fix captured and public ones first. - Replace
valbindings to mutable collections in shared objects with immutable collections, or document and encapsulate the mutation. - Enable
-Wsafe-initon Scala 3 builds and fix every initialization warning it reports. - Convert one multi-var loop into a case class plus
foldLeft, and add a unit test for the step function. - Audit lazy vals: remove cycles, keep initializers small, and use
@threadUnsafeonly where single-threaded access is guaranteed. - For any shared counter or flag, choose explicitly between @volatile, an atomic class and a Ref, and write the choice in a comment.