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.

Advertisement

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.

A binding and the object it points to are two different thingsval limit = 10binding fixedvar count = 0binding reassignableval buf = ArrayBuffer()binding fixedvar xs = List(1, 2)binding reassignableInt 10value, immutableInt 0 then 1 then 2new value per assignmentArrayBuffercontents mutableList(1, 2) then List(0, 1, 2)each list immutablerebindingsame object, new contentsrebindingsafe to sharelocal onlynot safe to sharesafe if not escapedval controls the arrow; the type of the target controls whether the box can change.
Four combinations of binding and value. Only the first is immutable all the way down. The third is the one that surprises people: the val cannot be reassigned, but its contents can change under every reader.

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 default

Two 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.

Advertisement

What the compiler generates

What the compiler generates for class membersval name: Stringfield + getter name()var hits: Intfield + hits() + hits_=(v)lazy val indexfield + init state + getterlocal var in a lambdaboxed as scala.runtime.IntRef@volatile var flagvolatile field + accessorsdef totalmethod, recomputed each callCallers never touch the field directly: uniform access means a def, val or var can back the same name.
Members compile to a field plus accessor methods. The uniform access principle means callers see a method call in every case.

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           // IllegalArgumentException

This 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)
  n

The 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 StringBuilder

Two 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

SymptomUsual causeFix
Value is null or 0 inside a traitAbstract val read during superclass initializationdef, lazy val or constructor parameter; enable -Wsafe-init
Counts are too low under loadvar += from several threads or callbacksAtomicLong, a Ref, or confine to one thread
Flag change never seen by a worker loopPlain var read in a tight loop@volatile var or an AtomicBoolean
Data changes behind a 'constant'val bound to a mutable collection that escapedImmutable collection, or copy on the way out
Deadlock at startup in Scala 2Cyclic lazy vals initialized from two threadsBreak the cycle; eager vals where possible
Assignments fail to compilevar type inferred too narrowly from its initializerDeclare 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

  1. Grep your codebase for var and classify each hit as local, captured, private field or public field; fix captured and public ones first.
  2. Replace val bindings to mutable collections in shared objects with immutable collections, or document and encapsulate the mutation.
  3. Enable -Wsafe-init on Scala 3 builds and fix every initialization warning it reports.
  4. Convert one multi-var loop into a case class plus foldLeft, and add a unit test for the step function.
  5. Audit lazy vals: remove cycles, keep initializers small, and use @threadUnsafe only where single-threaded access is guaranteed.
  6. For any shared counter or flag, choose explicitly between @volatile, an atomic class and a Ref, and write the choice in a comment.
Key takeaway: val fixes a binding and var allows it to change; neither says anything about whether the object at the end of the binding can change. Both compile to a field plus accessors, which is why a var can later grow a validating setter without touching callers. The real risks are not the keywords themselves but escape and sharing: captured vars become heap boxes that race, vals bound to mutable objects change behind readers, and abstract vals read during construction come back null. Default to val with immutable types, use a local var inside one method when measurement says it pays, and put shared mutable state behind an atomic class or an effect-system Ref.