Skip to content
← All insights
Scala15 min read

Should you migrate from Scala 2 to Scala 3?

A technical decision guide to compatibility, macros, dependencies, compiler migration modes, cross-building and a staged route from Scala 2.13 to Scala 3.

Scala 2.13 is not about to stop working

Migration should begin with a reason, not an assumed deadline. Scala 2.13 remains maintained, including security work, new JVM compatibility and continued interoperability with Scala 3. A service is not automatically at risk simply because its compiler has a 2 in the version.

The stronger reasons are local to the system: a library has moved its development to Scala 3, new internal modules need to share Scala 3 code, Scala 2-specific tooling is blocking JDK or framework upgrades, or the team expects enough future development to benefit from the newer language and ecosystem.

A stable Scala 2.13 service approaching retirement may be better left alone. A product expected to change for another five years has a different cost curve. Compare migration with the cost of staying: dependency availability, hiring, build friction, security response and the number of changes the team expects to make.

  • What concrete constraint does Scala 3 remove?
  • How long will the service continue to change?
  • Which supported library or framework versions are required?
  • Who will own the migrated code and build?

Identify the real starting point

The supported migration path starts from Scala 2.13. If the application is on 2.11 or 2.12, moving to 2.13 is a separate migration with its own collection, dependency and framework compatibility work. Do not hide that work inside a single 2-to-3 estimate.

Record the Scala binary version for every module, the JDK used locally and in CI, the sbt version, framework versions, compiler plugins, sbt plugins and internal artifacts. Run the build from a clean checkout. A migration plan based on a developer’s warm dependency cache is not yet reproducible.

Also establish which failures already exist. Warnings, flaky tests, eviction messages and generated-source differences need a baseline so they are not incorrectly attributed to the new compiler.

Interrogate the build before editing itsbt shell
show scalaVersion
show javaHome
show sbtVersion
plugins
show Compile / scalacOptions
dependencyTree
evicted
clean
Test / test

Dependencies determine whether migration is feasible

Scala dependencies are normally published for a particular binary line. In sbt, the double-percent operator selects the artifact for the project’s Scala binary version. Changing scalaVersion therefore changes the artifact coordinates sbt resolves, even when every declared library version stays the same.

The Scala 3 compiler can read signatures from many libraries compiled for Scala 2.13. That compatibility is useful during a staged migration, but it is not universal. A Scala 3 compiler cannot expand a Scala 2.13 macro. Scala 2 compiler plugins cannot run inside Scala 3, and code that relies on scala-reflect must be replaced or isolated.

Build a compatibility table before touching application syntax. Include direct and transitive macro libraries, compiler plugins introduced by sbt plugins, generated-code tools, test frameworks and private libraries. One abandoned internal macro can matter more than fifty ordinary dependencies.

  • Is a Scala 3 artifact published for the required library version?
  • Does the dependency expose Scala 2 macros at compile time?
  • Does an sbt plugin inject a Scala 2 compiler plugin?
  • Is scala-reflect present directly or transitively?
  • Can an internal library be cross-built before the application moves?
Use a Scala 2.13 artifact only as a deliberate bridgescala / sbt
libraryDependencies +=
  ("com.example" %% "domain-model" % domainVersion)
    .cross(CrossVersion.for3Use2_13)

// This can bridge an ordinary 2.13 library while it is ported.
// It cannot make a Scala 2 macro expandable by the Scala 3 compiler.

Make Scala 2.13 warn about Scala 3 first

Before changing compilers, enable Scala 2.13’s Scala 3 source mode. The -Xsource:3 option reports constructs whose meaning changes or which will not compile under Scala 3. Recent Scala 2.13 releases also understand selected Scala 3 syntax, which can reduce the amount of version-specific source in a cross-build.

Introduce the option on the existing compiler and review every diagnostic. It can initially be useful to report the migration category as warnings so the team sees the complete set; later, fail the build on newly introduced migration problems.

This is also the point to add explicit result types to public methods and values. Scala 3 has a redesigned inference algorithm, so leaving public APIs inferred can produce source or binary changes that are hard to distinguish from the intended migration.

Enable migration diagnostics only for Scala 2.13scala / sbt
scalacOptions ++= {
  if (scalaBinaryVersion.value == "2.13")
    Seq(
      "-Xsource:3",
      "-Wconf:cat=scala3-migration:w",
      "-deprecation",
      "-feature"
    )
  else Seq.empty
}

Compile with Scala 3 before adopting Scala 3 style

The first goal is not enums, indentation syntax or a new effect library. It is to compile the existing program with the Scala 3 compiler while changing as little behaviour as possible. Mixing a language migration, architecture refactor and dependency redesign removes the ability to explain a failure.

Scala 3 provides a migration source mode that accepts many dropped constructs with warnings. When the project compiles, the compiler can rewrite a number of those constructs automatically. Run rewrites only on a clean, committed worktree because they modify source files and apply as a set rather than as individually selected fixes.

Automatic rewriting handles syntax, not application semantics. Review the diff, compile again without migration mode, and keep generated sources out of the manual edit path. Remaining failures usually expose changed type inference, implicit resolution, macros or APIs rather than punctuation.

Use migration mode as a temporary compiler stagesbt shell
set scalacOptions += "-source:3.0-migration"
compile
reload

// After the migration compile succeeds, commit the baseline first.
set scalacOptions ++= Seq("-source:3.0-migration", "-rewrite")
compile

// Review the source diff, then remove migration mode and compile again.

Some source changes are mechanical; others change resolution

Scala 3 separates the jobs previously grouped under implicit. Context parameters use using, provided instances use given, and enrichment methods use extension. The new syntax is clearer, but a mechanical rewrite does not prove that the same instance wins when several candidates are in scope.

Test code that depends on ordering, fallback instances or imported implicits. Scala 3 wildcard imports do not automatically import givens, so an import that looks equivalent may provide a different context. Overloaded methods and inferred conversions deserve the same attention.

There is no need to rewrite every sealed trait as an enum or every implicit class as an extension method during the compiler move. First preserve the API and behaviour. Adopt Scala 3-native modelling later where it makes the code materially clearer.

Separate context, instance and extension intentscala
// Scala 2
object Scala2Service {
  def load(id: AccountId)(implicit trace: Trace): Future[Account] = ???
  implicit val trace: Trace = Trace.console
  implicit class AccountOps(account: Account) {
    def isOverdrawn: Boolean = account.balance < 0
  }
}

// Scala 3
object Scala3Service:
  def load(id: AccountId)(using Trace): Future[Account] = ???
  given Trace = Trace.console
  extension (account: Account)
    def isOverdrawn: Boolean = account.balance < 0

Cross-build where it reduces the blast radius

A multi-module repository does not have to change compiler everywhere at once. Shared libraries can often cross-build for Scala 2.13 and Scala 3 while applications migrate separately. Version-specific source directories provide a controlled escape hatch for compiler APIs, syntax or macro implementations that genuinely differ.

Cross-building is not free. Every conditional dependency and duplicated source file becomes maintenance work until Scala 2 support is removed. Use common source wherever possible and keep the compatibility period tied to a migration sequence rather than leaving two permanent implementations.

Macro libraries are the sharpest example. Their public non-macro data types may remain shared, but Scala 2 macro implementations and Scala 3 quoted implementations normally belong in separate source directories and require tests under both compilers.

Cross-build a library without forking the whole projectscala / sbt
val Scala213 = "2.13.18"
val Scala3   = "3.8.4"

ThisBuild / crossScalaVersions := Seq(Scala213, Scala3)
ThisBuild / scalaVersion := Scala213

// Shared code:       src/main/scala
// Scala 2-only code: src/main/scala-2
// Scala 3-only code: src/main/scala-3

// Verify both sides in CI:
// sbt "+test"

Compilation is necessary but not sufficient

A clean compile proves that types line up under the new compiler. It does not prove that external behaviour, serialised data or runtime wiring is unchanged. Migration tests should concentrate on places where Scala’s compile-time model leaks into runtime behaviour.

Check JSON and binary formats derived from case classes, reflection-based framework wiring, dependency injection, configuration decoding, route binding, database mappings and generated schemas. Snapshot or contract tests are useful when another service depends on the exact representation.

Operational behaviour matters too. Compare startup and shutdown, thread pools, blocking execution contexts, request latency, memory and error reporting on the same JDK and workload. If the JDK is changing as well, test the compiler change and runtime change as separate deployable checkpoints wherever the framework allows it.

  • Run unit, integration and contract tests under both compilers during transition
  • Compare API payloads and persisted representations
  • Exercise startup, graceful shutdown and failure paths
  • Check logging, metrics and tracing integrations
  • Canary the production artifact and retain a rollback path

Use explicit go and no-go criteria

Proceed when the service has enough useful life, required libraries and plugins have a viable Scala 3 route, important behaviour can be verified, and the team can support the result. The migration should unlock something identifiable: supported dependencies, a platform upgrade, simpler maintenance or a shared language direction.

Defer when a managed platform fixes the Scala version, an essential macro or compiler plugin has no replacement, the service is close to retirement, or the test and deployment baseline is too weak to distinguish migration failures from existing ones. In the last case, improving the baseline may be the right first project rather than abandoning migration entirely.

Define done beyond scalaVersion. A credible completion means the build resolves without unexplained overrides, migration modes and temporary compatibility flags are removed or documented, production behaviour is observed, rollback has been exercised, and the team understands any remaining Scala 2 bridge.