Migrating from Scala 2
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Scala 3.10.0 (released 2026-10-12) and Scala 2.13.18 (released 2025-11-24). Every command and program on it was run on 2026-10-15 and 2026-10-16 on JDK 26 and macOS, with Scala CLI 1.17.1 for the single-file experiments and sbt 1.13.0 with sbt-scala3-migrate 0.7.5 and sbt-scalafix 0.14.9 for the build experiments. The compilers (3.3.8, 3.9.0, 3.10.0 and 2.13.18), the libraries and the sbt plugins all came from Maven Central. Lines that are quoted from the official migration guide and were not run are labelled as such.
Moving a code base from Scala 2.13 to Scala 3 is less of a leap than it sounds, because the two compilers were designed to help each other. The Scala 2.13 compiler can warn about everything Scala 3 will reject, the Scala 3 compiler can read Scala 2.13 libraries and can rewrite most old syntax by itself, and the two can share one code base while you port it. This page is the recipe: what to check first, which flags and plugins do the mechanical work, how to port an sbt build, and what to do with macro libraries, the one part that cannot be converted automatically.
Related pages cover the surrounding questions. The four kinds of compatibility between the two languages and what
CrossVersion.for3Use2_13 really promises are in
Scala 2 & Scala 3 Compatibility. The list of
constructs that stopped working, with their replacements, is in
Incompatibilities & Dropped Features. Once
the project is on Scala 3, later compiler upgrades follow
Upgrading Between Scala 3 Versions. The build
tool itself is covered in sbt & Build Tools; for Maven, which
also builds Scala through a plugin, see the generic notes in
Java Build and Tooling.
The examples use the trailhead scenario: trails with a name and a length, and a Planner that computes walking times.
The route in one picture
The official guide recommends doing the work in small steps, with the code still compiling and passing its tests on Scala 2.13 after each one, so that any change of behavior is caught before the compiler is switched.
Which Scala 3 version to move to
The guide’s examples target Scala 3.0 and 3.3, but a project started today should pick a current line. Scala 3.9.0 (3 September 2026) is the LTS line that the Scala team advises for publishing libraries, and Scala 3.10.0 (12 October 2026) is the Scala Next line for applications that want the newest features. Both need JDK 17 or later, while the older 3.3 LTS still runs on JDK 8. A library that must stay usable from Scala 2.13 should stay on 3.9 LTS, because the release notes of 3.10 state that cross-compilation between Scala 2 and Scala 3.10 is no longer supported. The reasoning is in Versions, Compatibility & JDKs; the rest of this page works with any of them and says so when a command differs.
Source: Scala 3.10 released, All Scala versions
Prerequisites
A Scala 2.13 project can be ported when three conditions hold. It must not depend on a macro library that has no Scala 3
version, it must not use a compiler plugin without a Scala 3 equivalent, and it must not depend on scala-reflect.
Everything else, including most syntax, is handled by the tools below. The first thing to do is therefore to look at the
dependency list rather than at the code.
-
Macro libraries. The Scala 3 compiler cannot expand Scala 2 macros, so a library whose API is macro-based (logging, generic derivation, some test frameworks) must be upgraded to a version published for both 2.13 and 3. The official example is a test framework that moved from a 3.0.x release without Scala 3 support to a 3.2.x release that is published for both. Real run: using a Scala 2.13 macro method from Scala 3 stops the compiler at the call site.
-
Compiler plugins. Plugins are compiled against Scala 2 compiler internals and do not load into Scala 3. Look at the project’s compiler options (
show Compile / scalacOptionsin sbt lists every-Xpluginentry, including the ones that an sbt plugin adds behind your back). The commonly used ones have an answer, listed below. -
scala-reflect. The library exposes Scala 2 compiler internals that do not exist in Scala 3 and will not be ported. Code that uses it, or that consumes
Manifest, has to be rewritten with Java reflection or with the Scala 3 metaprogramming features; a transitive dependency on it usually means a library that needs upgrading.
The call to a Scala 2 macro from Scala 3, as run here (the macro was compiled by Scala 2.13.18 into a JAR, and the application by Scala 3.10.0 with that JAR on the class path; the library is the one used in the macro sections below):
-- Error: app3/Main.scala:4:10
4 | println(Macros.location)
| ^
|Scala 2 macro cannot be used in Scala 3. See https://docs.scala-lang.org/scala3/reference/dropped-features/macros.html
|To turn this error into a warning, pass -Xignore-scala2-macros to the compiler
1 error found
Compilation failed
Replacements for the compiler plugins that appear most often:
| Scala 2 plugin | What to do in Scala 3 |
|---|---|
SemanticDB |
Built into the Scala 3 compiler: |
Scala.js |
No compiler plugin any more; use |
Scala Native |
Supported since 0.4.3, and only with Scala 3.1.0 or newer; see Scala.js & Scala Native |
kind-projector |
A subset of its syntax is built in behind a compiler option, and type lambdas, polymorphic functions and kind polymorphism cover most of the rest; see below |
better-monadic-for, wartremover and similar |
No port; remove them and adapt the code, or ask the maintainers about a Scala 3 version |
The kind-projector option changed its name while Scala 3 matured. The guide and sbt-scala3-migrate 0.7.5 both say
-Ykind-projector, which was the correct name in Scala 3.3. Compiled with 3.9.0 it produced the warning
"Option -Ykind-projector is deprecated: Use -Xkind-projector instead", so a build that targets a recent compiler should
use -Xkind-projector. The concept pages that replace it are
Type Lambdas & Higher Kinds and the section on
polymorphic functions in
Dependent & Polymorphic Function Types.
Whether a dependency has a Scala 3 version can be checked on Scaladex, the index of Scala libraries: open the library, choose the version matrix and filter on Scala 3 and Scala 2.13. The sbt plugin below does the same check for the whole build in one command.
Step 1: -Xsource:3 on Scala 2.13
Before touching the Scala 3 compiler, ask the Scala 2.13 compiler what it would object to. The option -Xsource:3 turns
on migration warnings for the constructs that changed, and enables a few harmless Scala 3 syntaxes. Scala 2.13.14 and
newer split its two uses: plain -Xsource:3 is for migrating, and -Xsource-features:<features> additionally adopts
backported Scala 3 behavior, which is what libraries that cross-build between Scala 2 and 3 want.
A program in the style of a Scala 2 code base, with a few typical problems:
//> using scala 2.13.18
package trailhead
import scala.collection.mutable._
object Planner {
implicit val pace = 15
def minutes(km: Double)(implicit p: Int): Int = (km * p).toInt
def now() = System.currentTimeMillis() / 1000
def label(km: Double) = km + " km"
def main(args: Array[String]): Unit = {
val b = new ArrayBuffer[Int]
b += minutes(4.0)
println(b)
println(now > 0)
println(Option(label(1.5)).map(_.length))
}
}
Compiled with scala compile Planner.scala --scalac-option -Xsource:3, the Scala 2.13.18 compiler stops with an error,
because migration messages are errors by default:
Planner.scala:7: error: Implicit definition must have explicit type (inferred Int) [quickfixable]
Scala 3 migration messages are issued as errors under -Xsource:3. Use -Wconf or @nowarn to demote them to warnings or suppress.
Applicable -Wconf / @nowarn filters for this fatal warning: msg=<part of the message>, cat=scala3-migration, site=trailhead.Planner.pace
implicit val pace = 15
^
1 error
Compilation failed
Implicit definitions need an explicit type in Scala 3, and that is the first migration message. The fix does not have to
be typed by hand: the label [quickfixable] means the compiler can apply it. The option -quickfix takes a message
filter in the same style as -Wconf; cat=scala3-migration selects every migration message:
scala compile Planner.scala --scalac-option -Xsource:3 --scalac-option -quickfix:cat=scala3-migration
Planner.scala:7: error: [rewritten by -quickfix] Implicit definition must have explicit type (inferred Int)
...
1 error
Compilation failed
The compilation still fails, because the error was reported before it was fixed, but the source file has changed, and the next compilation is clean:
- implicit val pace = 15
+ implicit val pace: Int = 15
Passing a second filter in the same option (-quickfix:cat=scala3-migration|cat=deprecation) is rejected with
"Unknown category", so give one filter at a time. Metals shows the same quick fixes as code actions; IntelliJ did not at
the time of the guide.
To see everything instead of stopping at the first error, demote the migration messages to warnings with
-Wconf:cat=scala3-migration:w and add -deprecation:
Planner.scala:19: warning: Auto-application to `()` is deprecated. Supply the empty argument list `()` explicitly to invoke method now,
or remove the empty argument list from its definition (Java-defined methods are exempt).
In Scala 3, an unapplied method like this will be eta-expanded into a function. [quickfixable]
println(now > 0)
^
Planner.scala:7: warning: Implicit definition must have explicit type (inferred Int) [quickfixable]
implicit val pace = 15
^
Planner.scala:13: warning: method + in class Double is deprecated (since 2.13.0): Adding a number and a String is deprecated. Use the string interpolation `s"$num$str"`
def label(km: Double) = km + " km"
^
3 warnings
The first warning matters. Scala 3 does not allow auto-application: a method defined with an empty parameter list must
be called with (). The same program compiled with Scala 3.10.0 stops on exactly that line:
-- [E100] Syntax Error: Planner.scala:19:12
19 | println(now > 0)
| ^^^
| method now must be called with () argument
On Scala 2.13 it is only a deprecation warning, so -deprecation belongs in the options during the migration.
What -Xsource:3 enables and changes
Besides the warnings, the option accepts these Scala 3 forms on Scala 2.13, so that code that uses them still compiles on
both: import p.*, import p.m as n, import p.{given, *}, the pattern case C(xs*) as an alias for case C(xs @ _*),
and A & B as an alias for A with B.
Under -Xsource-features (or the shorthand -Xsource:3-cross, which stands for -Xsource:3 -Xsource-features:_) the
compiler does not warn about a changed behavior but adopts it. These are the behaviors, as listed in the guide for 2.13.18
and not run here individually:
| Feature flag | Plain -Xsource:3 |
With the feature enabled |
|---|---|---|
|
deprecation warning for |
does not compile |
|
fatal warning, the escape in |
the escape is not processed, as in Scala 3 |
|
fatal warning, a leading operator starts a new expression |
a leading infix operator continues the previous line |
|
fatal warning if a |
desugaring always uses |
|
fatal warning |
the package prefix is no longer part of the implicit scope |
|
fatal warning |
Scala 3 specificity rules for implicit search and overloading |
|
fatal warning |
constructor modifiers are copied to |
|
fatal warning at use sites |
synthetic companions of case classes no longer extend |
|
fatal warning |
an overriding method without a result type takes the result type of the method it overrides |
|
fatal warning |
definitions that differ only in empty parentheses are a double-definition error |
|
compile error "missing argument list" |
|
To avoid silent language changes when moving to a newer 2.13.x, enable features explicitly or by a version group, such as
-Xsource-features:v2.13.14. Three of the features change the bytecode (case-apply-copy-access,
case-companion-function and infer-override). For a library that already publishes both a 2.13 and a 3 artifact,
enabling them breaks binary compatibility, so the guide recommends leaving those three out
(-Xsource-features:v2.13.14,-case-apply-copy-access,-case-companion-function,-infer-override) and checking with MiMa;
the library authors' side of that story is in
Versions, Compatibility & JDKs.
The infer-override row is the one most likely to bite an application, and it shows what the quick fixes protect. With a
parent that declares def grade: Object and a child that writes def grade = "II", Scala 2 infers String for the
child, while Scala 3 infers Object, and a caller that wrote val g: String = new Alpine().grade stops compiling. In the
sbt project used later on this page, -Xsource:3 reported it as
Trails.scala:8:34: in Scala 3 (or with -Xsource-features:infer-override), the inferred type changes to Object instead of String [quickfixable]
class Alpine extends Climb { def grade = "II" }
^
and -quickfix rewrote the definition to def grade: String = "II", which means the same in both languages.
Step 2: the Scala 3 migration mode
The Scala 3 compiler carries its own help for migrating. The option -source selects the language rules, and each version
has a -migration variant that behaves like it but turns the errors for removed constructs into migration warnings. In
combination with -rewrite, the compiler patches the source. The values accepted by Scala 3.10.0 are 3.0 to 3.11,
with a -migration variant for each, plus future and future-migration. The default is the compiler’s own version, so
every removal since 3.0 is an error unless you choose an older level.
The next file is a Scala 2 program that mixes three typical removals: procedure syntax, _ as a wildcard type argument,
and : _* for passing a sequence as varargs.
//> using scala 3.10.0
package trailhead
import scala.collection.mutable._
case class Trail(name: String, lengthKm: Double)
object Legacy {
implicit class RichTrail(t: Trail) {
def minutes: Int = (t.lengthKm * 15).toInt
}
def describe(t: Trail) {
println(t.name + " takes " + t.minutes + " min")
}
def total(ts: Trail*): Double = ts.map(_.lengthKm).sum
def sizeOf(xs: List[_]): Int = xs.size
def main(args: Array[String]): Unit = {
val ts = List(Trail("Pico", 12.5), Trail("Lago", 4.0))
ts.foreach(describe)
println(total(ts: _*))
println(sizeOf(ts))
val b = new ArrayBuffer[Int]
b += 1
println(b)
}
}
With the default rules the compiler reports one error and two warnings. Procedure syntax, a method body without =,
was removed in Scala 3.0, so it is an error; the other two are deprecated since 3.4 and still compile:
-- [E040] Syntax Error: Legacy.scala:12:25
12 | def describe(t: Trail) {
| ^
| '=' expected, but '{' found
-- Warning: Legacy.scala:18:22
18 | def sizeOf(xs: List[_]): Int = xs.size
| ^
|`_` is deprecated for wildcard arguments of types: use `?` instead
|This construct can be rewritten automatically under -rewrite -source 3.4-migration.
-- Warning: Legacy.scala:23:22
23 | println(total(ts: _*))
| ^
|The syntax `x: _*` is no longer supported for vararg splices; use `x*` instead
|This construct can be rewritten automatically under -rewrite -source 3.4-migration.
2 warnings found
1 error found
Compilation failed
What each source level does with this file, as run with --scalac-option -source:<level>:
| Level | Result on 3.10.0 |
|---|---|
|
one migration warning: "Procedure syntax no longer supported; |
|
the procedure syntax is a hard error again, because the 3.4 rules removed it, and the two warnings above, which now say they can be rewritten under |
default (3.10) |
the error and warnings shown above |
The error message names the level that can fix each problem, so the recipe is mechanical: pick the oldest level that
reports the error, compile once with -rewrite, and then move to the next level. Rewriting compiles the file and patches
it in place; commit first, so that the changes can be reviewed as a diff. First pass:
scala compile Legacy.scala --scalac-option -source:3.0-migration --scalac-option -rewrite
[patched file Legacy.scala]
which changed one line, def describe(t: Trail) { into def describe(t: Trail): Unit = {. The second pass at the next
level:
scala compile Legacy.scala --scalac-option -source:3.4-migration --scalac-option -rewrite
printed the two warnings followed by [patched file Legacy.scala] and changed two more lines:
- def sizeOf(xs: List[_]): Int = xs.size
+ def sizeOf(xs: List[?]): Int = xs.size
...
- println(total(ts: _*))
+ println(total(ts*))
After the two passes the file compiles with no options, and running it prints
Pico takes 187 min
Lago takes 60 min
16.5
2
ArrayBuffer(1)
Three limits of the automatic rewrite are worth knowing, all stated by the guide:
-
The rewrites are not applied when the compilation ends in an error. If one construct is not rewritable, nothing is patched until you fix it by hand.
-
You cannot choose which rules run; the compiler applies all of them.
-
The mode forgives most dropped features but not all. Existential types, for example, have no automatic rewrite:
//> using scala 3.10.0
import scala.language.existentials
object Ex {
def first(xs: List[T] forSome { type T }): Any = xs.head
def main(args: Array[String]): Unit = println(first(List(1, 2)))
}
Compiled with -source:3.0-migration -rewrite, no file was patched, and the compiler explained the error:
-- [E034] Syntax Error: Ex.scala:5:24
5 | def first(xs: List[T] forSome { type T }): Any = xs.head
| ^^^^^^^
| Existential types are no longer supported -
| use a wildcard or dependent type instead
Adding -explain (and -explain-types for type mismatches) prints a longer text. For this error it ends with the advice
to replace List[T forSome { type T }] with the wildcard form List[?]. Both options are useful beyond the migration;
the full table of what was removed and what replaces it is
Incompatibilities & Dropped Features, and the
option catalogue is in
Compiler Options, Linting & Errors.
Once the code compiles on Scala 3, a second family of rewrites can convert it to the optional new syntax (indentation
instead of braces, then, and so on) with -rewrite -indent and related options. They are independent of the migration
and are not needed to finish it; see
Optional Braces & Indentation and the guide’s
Scala 3 Syntax Rewriting page, which
were not run for this page.
Step 3: porting an sbt project with sbt-scala3-migrate
sbt-scala3-migrate is an sbt plugin from the Scala Center that applies the steps above to a whole build. It works on one module at a time and offers four sbt commands to run in order. Its requirements, as stated by the guide, are Scala 2.13 (2.13.13 preferred), sbt 1.5 or newer, and a module that does not contain macro definitions: the plugin cannot migrate macro libraries. The latest release at the check date was 0.7.5 (January 2026), published for sbt 1.x. No artifact for sbt 2 was found on Maven Central, so a project on sbt 2 either runs the plugin from a temporary sbt 1 build or follows the by-hand route below.
The project used for the run below is a small Scala 2.13 build of the trailhead scenario, with the typical problems of a real one: a dependency that is not published for Scala 3 in its current version, a macro library, two compiler plugins, and sources with procedure syntax, auto-application and an inferred override type. The starting build file:
lazy val main = project
.in(file("."))
.settings(
name := "trailhead",
scalaVersion := "2.13.18",
scalacOptions ++= Seq("-encoding", "UTF-8", "-target:jvm-1.8", "-Xsource:3", "-Wunused:imports,privates,locals", "-explaintypes"),
libraryDependencies ++= Seq(
"org.typelevel" %% "cats-core" % "2.4.0",
"org.scalameta" %% "parsers" % "4.8.9",
"org.scalatest" %% "scalatest" % "3.2.19" % Test,
"com.softwaremill.scalamacrodebug" %% "macros" % "0.4.1" % Test
),
addCompilerPlugin(("org.typelevel" %% "kind-projector" % "0.13.4").cross(CrossVersion.full)),
addCompilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1")
)
1. Install the plugin
Add it to project/plugins.sbt, with sbt 1.x in project/build.properties (here sbt.version=1.13.0):
addSbtPlugin("ch.epfl.scala" % "sbt-scala3-migrate" % "0.7.5")
From then on every sbt start prints a reminder with the four commands, which goes away when the plugin is removed:
[info] sbt-scala3-migrate 0.7.5 detected!
[info] It can assist you during the migration to Scala 3.
[info] Run the following commands in order, to start migrating to Scala 3:
[info] 1. migrateDependencies <project>
[info] 2. migrateScalacOptions <project>
[info] 3. migrateSyntax <project>
[info] 4. migrateTypes <project>
If the build has several modules, start with the one that has the fewest dependencies, because Scala 2.13 and Scala 3 modules can depend on each other in either order. The command argument must not be an aggregate project.
2. migrateDependencies
migrateDependencies main checks every library and compiler plugin against Scala 3 and sorts them into groups. The
output for the build above, as run:
[info] Starting migration of libraries and compiler plugins in project 'main'
[info]
[info] Valid dependencies:
[info] "org.scalatest" %% "scalatest" % "3.2.19" % Test
[warn]
[warn] Versions to update:
[warn] "org.typelevel" %% "cats-core" % "2.6.1" (Other versions: 2.7.0, ..., 2.13.0)
[warn] "org.scalameta" %% "parsers" % "4.12.1" (Other versions: 4.12.2, ..., 4.17.4)
[warn]
[warn] Integrated compiler plugins:
[warn] addCompilerPlugin(("org.typelevel" %% "kind-projector" % "0.13.4").cross(CrossVersion.full))
[warn] replaced by scalacOptions += "-Ykind-projector"
[error]
[error] Incompatible Libraries:
[error] "com.softwaremill.scalamacrodebug" %% "macros" % "0.4.1" % Test (Macro Library)
[error] addCompilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1") (Compiler Plugin)
[success] Total time: 2 s
Each group asks for a different action:
-
Valid dependencies are Java libraries or libraries already cross-published for Scala 3. Keep them.
-
Versions to update have a Scala 3 artifact only in a later version. The number shown is the first one that works; newer ones (the "Other versions" part) are fine as long as the bump is source compatible, which semantic versioning promises for patch and minor bumps and not for major ones. Here
cats-corewas raised to 2.6.1. -
For Scala 3 use 2.13 (not present in this run, shown in the guide) lists libraries that exist only for 2.13 but can be used from Scala 3 through
("org.scalameta" %% "parsers" % "4.8.9").cross(CrossVersion.for3Use2_13). The guide warns that this can create a conflict between the_2.13and_3suffixes of a transitive dependency, in which case sbt fails with a clear message, and that publishing a Scala 3 library that depends on a Scala 2.13 library is generally not safe. The compatibility rules are in Scala 2 & Scala 3 Compatibility. -
Integrated compiler plugins are now compiler flags. During the migration the build must still compile on Scala 2.13, because the next commands use that compiler to patch the code, so the plugin is made conditional on the Scala version.
-
Incompatible libraries have no Scala 3 version. Ask the maintainers about a port or remove the dependency and adapt the code. In this run both were removed.
The updated dependency section, which still builds on 2.13:
libraryDependencies ++= Seq(
"org.typelevel" %% "cats-core" % "2.6.1",
"org.scalatest" %% "scalatest" % "3.2.19" % Test
),
libraryDependencies ++= {
if (scalaVersion.value.startsWith("3.")) Seq.empty
else Seq(
compilerPlugin(("org.typelevel" %% "kind-projector" % "0.13.4").cross(CrossVersion.full))
)
},
Reload, check that it compiles and tests pass on 2.13, and commit.
3. migrateScalacOptions
The Scala 3 compiler has a different set of options. migrateScalacOptions main sorts the current ones:
[info] Starting migration of scalacOptions in project 'main'
[info]
[info] Valid scalacOptions:
[info] -encoding UTF-8
[info] -Wunused:imports,privates,locals
[warn]
[warn] Renamed scalacOptions:
[warn] -target:jvm-1.8 -> -Xunchecked-java-output-version:8
[warn] -explaintypes -> -explain
[warn]
[warn] Removed scalacOptions:
[warn] -Xsource:3
[warn] -Yrangepos
Some options listed may come from sbt or from another sbt plugin rather than from your build file; keep those plugins current and they adapt themselves. The build then keeps one list per compiler, again so that it still compiles on 2.13:
scalacOptions ++= {
if (scalaVersion.value.startsWith("3.")) scala3Options
else scala2Options
}
lazy val sharedScalacOptions =
Seq("-encoding", "UTF-8", "-Wunused:imports,privates,locals")
lazy val scala2Options = sharedScalacOptions ++
Seq("-target:jvm-1.8", "-Xsource:3", "-explaintypes")
lazy val scala3Options = sharedScalacOptions ++
Seq("-Xunchecked-java-output-version:17", "-explain", "-Xkind-projector")
Two values differ from what the plugin printed. The plugin suggested -Xunchecked-java-output-version:8, and Scala 3.9.0
rejected it later in this run with "8 is not a valid choice for -Xunchecked-java-output-version", because Scala 3.8 and
newer need JDK 17 or later. And -Ykind-projector is now -Xkind-projector, as noted above. The full old-to-new mapping
is on the guide’s options lookup page,
Compiler Options Lookup Table.
4. migrateSyntax
migrateSyntax main runs Scalafix rules that patch syntax. The rules are ProcedureSyntax,
fix.scala213.ExplicitNullaryEtaExpansion, migrate.ParensAroundLambda, fix.scala213.ExplicitNonNullaryApply,
fix.scala213.Any2StringAdd and ExplicitResultTypes. In this project the earlier -quickfix pass had already removed
the procedure syntax and the lambda without parentheses, so what remained was the auto-application:
[info] Starting migration of syntax in project 'main'
[info] Run syntactic rules in 1 Scala source successfully
[info] Applied 2 patches in src/main/scala/trailhead/Trails.scala
[info] Run syntactic rules in 1 Scala source successfully
[info] Migration of syntax in main succeeded.
- def now() = System.currentTimeMillis() / 1000
+ def now(): Long = System.currentTimeMillis() / 1000
...
- println(now > 0)
+ println(now() > 0)
Some incompatibilities are not covered by the rules. They are rare and easy to fix by hand with the help of the table in Incompatibilities & Dropped Features. Review the patches, run the tests and commit.
5. migrateTypes
The Scala 3 type inference differs in details, so some inferred types now cause errors. migrateTypes main finds them by
compiling with Scala 3, adds the type ascriptions that make the code compile, and re-checks. Here, with the previous steps
done, it found nothing left to patch and recommended a version:
[info] Migrating types in main / Compile
[info]
[info] Compiling to Scala 3 with -source:3.0-migration -rewrite
[info] compiling 1 Scala source to target/scala-3.3.7/classes ...
[info] done compiling
[info]
[info] Migrating types in main / Test
[info]
[info] Compiling to Scala 3 with -source:3.0-migration -rewrite
[info]
[info] You can safely upgrade main to Scala 3:
[info] scalaVersion := "3.3.7"
Notice the version: the plugin recommends a Scala 3.3 release, not a newer line. The recommendation says that the code compiles with the migration compiler; the version to use in the build is your choice, as discussed at the top of this page. The guide’s own run of the command, on a project with more type problems, listed the candidate patches it found and applied only the ones that were needed.
6. Switch the build
For one module, set scalaVersion to the target. For several modules, repeat from step 2 on the next one. When all modules
are done, remove sbt-scala3-migrate from project/plugins.sbt together with the Scala 2.13 settings. The next section
shows the cross-building setup, which is a good way to keep both compilers available while the switch is verified.
Step 4: porting an sbt project by hand
The plugin is optional. The manual route does the same with the compilers' own flags and works for any build tool that can build Scala 3, as the guide points out. The core idea is to cross-build: compile and test the same sources with Scala 2.13 and with Scala 3, so that after every fix the Scala 2.13 test run proves that the behavior did not change. The main risk of the move is not compile errors, which the compiler reports, but silent runtime differences, for example a different implicit being resolved, and only tests catch those.
Set up cross-building
scalaVersion := "3.9.0"
crossScalaVersions := Seq("2.13.18", "3.9.0")
The default version is the first one in scalaVersion; the command ++2.13.18 switches the current session to the other,
and reload always returns to the default. On the migrated project, both compilers ran the same tests (the commands were passed to sbt in batch mode, shown here as
in the shell, with the output shortened):
sbt> ++2.13.18
[info] Setting Scala version to 2.13.18 on 1 projects.
sbt> test
[info] - minutes
[info] - sum of lengths
[info] Tests: succeeded 2, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
sbt> ++3.9.0
[info] Setting Scala version to 3.9.0 on 1 projects.
sbt> test
[info] compiling 1 Scala source to target/scala-3.9.0/classes ...
[info] done compiling
[info] - minutes
[info] - sum of lengths
[info] All tests passed.
sbt> run
Pico takes 187 min
Lago takes 60 min
16.5
List(12.5, 4.0)
true
II
A version that is not listed in crossScalaVersions is not selected by plain `: with `3.10.0, sbt 1.13.0 reported
"Setting Scala version to 3.9.0", the listed Scala 3 entry. To try the newest compiler anyway, force it with the ! suffix and a space, ++ 3.10.0!. The same project, cleaned and run
that way, compiled with Scala 3.10.0 and passed its two tests with the same output.
The first Scala 3 compile of the project failed twice, and both failures are the option problems described in step 3:
-Ykind-projector printed "Option -Ykind-projector is deprecated: Use -Xkind-projector instead" and
-Xunchecked-java-output-version:8 was rejected. They are worth remembering because both options come straight out of
the migration plugin and the guide.
Prepare the dependencies
If you did not use the plugin, compile on Scala 3 will report unresolved dependencies, because the declared versions are
not published for Scala 3. For each one either upgrade to a version that is, or use the 2.13 artifact:
("com.lihaoyi" %% "os-lib" % "0.7.7").cross(CrossVersion.for3Use2_13)
// for a Scala.js dependency (sbt 1.x syntax; sbt 2 uses %% for both):
("com.lihaoyi" %%% "os-lib" % "0.7.7").cross(CrossVersion.for3Use2_13)
Make the same change in every module of the build, and check on Scala 2.13 that nothing broke. The opposite direction,
using a Scala 3 library from Scala 2.13, is CrossVersion.for2_13Use3 and is limited; see the compatibility page. These
lines are quoted from the guide and were not run here.
Configure the Scala 3 compiler for the migration
The guide suggests a common list, a Scala 2.13 list and a Scala 3 list, with the migration mode switched on for Scala 3 and fatal warnings disabled during the migration so that the automatic rewrites can complete:
scalacOptions ++= {
Seq(
"-encoding", "UTF-8",
"-feature",
"-language:implicitConversions"
// disabled during the migration
// "-Xfatal-warnings"
) ++
(CrossVersion.partialVersion(scalaVersion.value) match {
case Some((3, _)) => Seq(
"-unchecked",
"-source:3.0-migration"
)
case _ => Seq(
"-deprecation",
"-Xfatal-warnings",
"-Wunused:imports,privates,locals",
"-Wvalue-discard"
)
})
}
(Quoted from the guide, not run as one block.) On a recent Scala 3 compiler -Xfatal-warnings is a deprecated alias: the compiler prints
"Option -Xfatal-warnings is a deprecated alias: use -Werror instead", and -Werror is the current name.
Solve the incompatibilities
Compile with Scala 3. The compiler produces two kinds of diagnostics. Migration warnings are fixed automatically at the end; errors must be fixed by hand, using the table in Incompatibilities & Dropped Features. After each fix, run the tests on Scala 2.13 again, and prefer fixes that preserve binary compatibility, which matters most for a published library. Macro incompatibilities are the exception: they cannot be patched, because the macro code must be rewritten from scratch (see below).
Once the errors are gone, only migration warnings remain. Rewrite them by compiling once more with -rewrite, as in step
2, then remove -source:3.0-migration and put -Xfatal-warnings back. In sbt that is a temporary setting:
sbt> ++3.9.0
sbt> set main/scalacOptions += "-rewrite"
sbt> main/compile
[info] [patched file /example/src/main/scala/app/Main.scala]
(The guide’s session, quoted.) Keep in mind what step 2 showed: one -source level reports only what is removed up to that
level, so a project that uses constructs removed in 3.4 needs a second pass at -source:3.4-migration.
Validate and finish
Run the tests on both versions and set the continuous integration pipeline up for Scala 3. Tests are the only guarantee
against the rare case in which a different implicit value is chosen and changes the behavior. Then drop the 2.13 entry
from crossScalaVersions if you do not publish for Scala 2, or keep it if the library supports both. A library that
publishes both artifacts should check compatibility with MiMa and read
Versions, Compatibility & JDKs first.
Scalafix
Scalafix is the refactoring tool that the sbt plugin and the incompatibility table
rely on, and it can be run on its own. Per the guide, the rules relevant to a Scala 3 migration are procedure syntax,
explicit result types, and three rules from the scala/scala-rewrites repository: ExplicitNullaryEtaExpansion,
ExplicitNonNullaryApply and Any2StringAdd. They are applied with the sbt-scalafix plugin, and the project needs
SemanticDB:
// project/plugins.sbt
addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.9")
// build.sbt
scalaVersion := "2.13.18"
semanticdbEnabled := true
semanticdbVersion := scalafixSemanticdb.revision
The built-in rules run without a problem. On a file with procedure syntax and an inferred public result type:
object Clock {
implicit val pace = 15
def tick() {
println(System.currentTimeMillis() / 1000)
}
def label(km: Double) = s"$km km"
}
scalafix ProcedureSyntax and scalafix ExplicitResultTypes produced
- def tick() {
+ def tick(): Unit = {
...
- def label(km: Double) = s"$km km"
+ def label(km: Double): String = s"$km km"
and left implicit val pace = 15 alone, because ExplicitResultTypes skips simple literal definitions by default; the
Scala 2.13 compiler still warned about it ("Implicit definition should have explicit type (inferred Int)"), which the
-quickfix of step 1 fixes.
The external rules were a different story at the check date. scala-rewrites 0.1.5, the latest release, is published
under org.scala-lang, and it was built against Scalafix 0.11.0. Applied through sbt-scalafix 0.14.9, with the command
scalafix dependency:fix.scala213.ExplicitNonNullaryApply@org.scala-lang:scala-rewrites:0.1.5, it printed
[info] Loading external rule(s) built against an old version of Scalafix (0.11.0).
...
[error] java.lang.NoSuchMethodError: 'boolean scala.meta.internal.trees.package$XtensionTreesName$.isReference$extension(scala.meta.Name)'
[error] at fix.scala213.ExplicitNonNullaryApply.$anonfun$unsafeFix$4(ExplicitNonNullaryApply.scala:61)
The message itself suggests the workaround of downgrading Scalafix to 0.11.x. That route did not work on this setup: with
sbt-scalafix 0.11.1 the plugin failed first on a SemanticDB version that does not exist for 2.13.18, and then, after
overriding it, on "Scala version mismatch … Scalafix is running on Scala 2.12.18", so the external rules were not run
successfully. Practical advice: use the -quickfix and -Xsource:3 of step 1 for the auto-application and eta-expansion
problems on 2.13, use sbt-scala3-migrate (which bundles its own copies of these rules), and keep the Scalafix route for
the built-in rules. The Scalafix and scala-rewrites projects are third-party tooling and change independently of Scala.
Formatting and editors during the migration
Two tools need a small configuration change and are quoted here from the guide, not run. Scalafmt supports Scala 3 since
3.0.0, but the dialect must be set: runner.dialect = scala3 in .scalafmt.conf, or selectively with a fileOverride
that applies the dialect to the Scala 3 source directories only, which is useful while a cross-built project still has
Scala 2 sources:
fileOverride {
"glob:**/scala-3*/**" {
runner.dialect = scala3
}
}
For editors, Metals (VS Code, Vim, Emacs and others) and the IntelliJ Scala plugin both support Scala 3; see
IDEs. Build tool support is not an obstacle either: sbt supports Scala 3
out of the box since 1.5 and the old sbt-dotty plugin is not needed, Mill supports it, and the Scala Maven plugin has
supported it since 4.5.1. The build tools are compared in
sbt & Build Tools.
Macros
Scala 2 macros are the one part of a code base that cannot be migrated by a tool. The Scala 3 compiler cannot expand them,
and Scala 3 has a different macro system, built from inline, quotes and splices (see
Metaprogramming Overview and
Macros: Quotes & Splices). Application code that merely
uses macro libraries is covered by the prerequisites above. A library that defines macros has two options, and the guide
describes both with the same small example: a macro location that returns the file and line of its call site.
-
Cross-building: build the artifact twice,
example_2.13for Scala 2.13 users andexample_3for Scala 3 users, with a different implementation in each. -
Mixing macros: ship one Scala 3 artifact that carries both the Scala 3 macro and the Scala 2 macro definition, so that Scala 2.13 users can consume it with the TASTy reader.
Both options were built and run here with Scala CLI instead of sbt, so that each step is visible. The class that both variants return:
package location
case class Location(path: String, line: Int)
Cross-building a macro library
The Scala 2 variant is the original macro, and it needs scala-reflect on its class path:
//> using dep org.scala-lang:scala-reflect:2.13.18
package location
import scala.reflect.macros.blackbox.Context
import scala.language.experimental.macros
object Macros {
def location: Location = macro locationImpl
def locationImpl(c: Context): c.Tree = {
import c.universe._
val location = typeOf[Location]
val line = Literal(Constant(c.enclosingPosition.line))
val path = Literal(Constant(c.enclosingPosition.source.path))
q"new $location($path, $line)"
}
}
The Scala 3 variant must have the same signature, and is written with quotes and splices:
package location
import scala.quoted.{Quotes, Expr}
object Macros:
inline def location: Location = ${ locationImpl }
private def locationImpl(using quotes: Quotes): Expr[Location] =
import quotes.reflect.Position
val pos = Position.ofMacroExpansion
val file = Expr(pos.sourceFile.name)
val line = Expr(pos.startLine + 1)
'{ new Location($file, $line) }
Note the + 1: the Scala 3 position API counts lines from zero, while enclosingPosition.line of Scala 2 starts at one.
(The guide’s Scala 3 version records the full source path with pos.sourceFile.path.toString; this version stores only
the file name, which is why its output below differs from the Scala 2 one.)
In an sbt build, the shared class stays in src/main/scala, and the files that only one compiler can read go to
src/main/scala-2 and src/main/scala-3, a version-specific directory convention that sbt provides by default. The
guide’s final layout is:
src/main/scala/*.scala cross-compatible classes
src/main/scala-2/*.scala the Scala 2 implementation of the macro methods
src/main/scala-3/*.scala the Scala 3 implementation of the macro methods
src/test/scala/*.scala common tests
(The sbt layout is quoted from the guide; the sources were compiled with Scala CLI.) Compiling each variant into its own JAR and calling the macro from an application, the Scala 3 application printed the file and line:
import location.Macros
@main def where(): Unit =
println(Macros.location)
Location(Main.scala,4)
and the Scala 2.13.18 application, which has the same call on line 5, printed the full path that its macro records:
Location(.../app2/Main.scala,5)
The two artifacts are separate: the Scala 3 application given the Scala 2 JAR gets the error shown under prerequisites. Cross-validate with a test that runs on both compilers, as the guide does with a single MUnit test that asserts the line number; this protects against the two implementations drifting apart.
Mixing Scala 2.13 and Scala 3 macros
The alternative is a single artifact built with Scala 3. The Scala 3 compiler can read Scala 2 definitions, and a Scala
2.13 compiler with the TASTy reader can read Scala 3 definitions, so one object can define the same method twice, once as
a Scala 2 macro and once as a Scala 3 inline macro. Each compiler picks the definition it understands. Because the Scala
3 compiler can compile a Scala 2 macro implementation only when it contains no quasiquotes or reification, the Scala 2
implementation usually lives in a small Scala 2.13 module that the main module depends on. That module also has to own the
Location class, which is why the guide says it "had to be moved downstream":
//> using dep org.scala-lang:scala-reflect:2.13.18
package location
import scala.reflect.macros.blackbox.Context
case class Location(path: String, line: Int)
object Scala2MacrosCompat {
def locationImpl(c: Context): c.Tree = {
import c.universe._
val location = typeOf[Location]
val line = Literal(Constant(c.enclosingPosition.line))
val path = Literal(Constant(c.enclosingPosition.source.path))
q"new $location($path, $line)"
}
}
The Scala 3 module (compiled with the compat module’s JAR on the class path) defines both:
//> using dep org.scala-lang:scala-reflect:2.13.18
package location
import scala.language.experimental.macros
import scala.quoted.{Quotes, Expr}
object Macros:
def location: Location = macro Scala2MacrosCompat.locationImpl
inline def location: Location = ${ locationImpl }
private def locationImpl(using quotes: Quotes): Expr[Location] =
import quotes.reflect.Position
val pos = Position.ofMacroExpansion
val file = Expr(pos.sourceFile.name)
val line = Expr(pos.startLine + 1)
'{ new Location($file, $line) }
location is not an overloaded method: the compiler recognizes the first definition as Scala 2 only and the second as
Scala 3 only. Two details were found by running it, and neither is mentioned in the guide’s listing:
-
Without
import scala.language.experimental.macrosthe Scala 3 compiler refuses the file with "Scala 2 macro definition needs to be enabled by making the implicit value scala.language.experimental.macros visible" (the option-language:experimental.macrosdoes the same). -
The Scala 3 module needs
scala-reflecton its class path, otherwise the reference toScala2MacrosCompat.locationImplfails with "Bad symbolic reference … refers to Context/T in package scala.reflect.macros.blackbox which is not available". In sbt the dependency on the compat module brings it transitively; here it is declared.
Built with 3.3.8 and with 3.10.0, both mixed libraries expanded correctly in a Scala 3.10.0 application, which printed
Location(Main.scala,4). A Scala 2.13.18 application with the same call then used the library through the TASTy reader.
Without the option, the compiler stops and tells you what to add:
error: error while loading Macros, Missing dependency 'Add -Ytasty-reader to scalac options to parse the TASTy in Macros.tasty)', required by mixed-3.3.8.jar(location/Macros.tasty)
1 error
Compilation failed
With -Ytasty-reader, the application compiled against the library built by 3.10.0 and printed the Scala 2 macro’s
output, Location(.../app2/Main.scala,5). Against the library built by 3.3.8 it first failed with "could not find package
scala.annotation.internal whilst reading annotation of package location", until org.scala-lang:scala3-library_3:3.3.8
was added to the class path; then it printed the same result. In a published artifact the dependency would normally come in
transitively; the guide’s instructions for a consumer are:
scalaVersion := "2.13.12"
libraryDependencies += ("org" %% "example" % "x.y.z").cross(CrossVersion.for2_13Use3)
scalacOptions += "-Ytasty-reader"
(The consumer snippet is the guide’s, not run.) The 3.10.0 result is not a sign that mixing works with recent compilers.
The limit is the one from the compatibility page: Scala 2.13’s -Ytasty-reader supports Scala 3 libraries built by 3.7
or older, and from 3.8 on it is unsupported. Re-run with 2.13.18, a small library with a case class and an object
compiled and ran when it was built by 3.7.4, and failed to compile when it was built by 3.8.3, 3.9.0 or 3.10.0, even with
scala3-library_3 of the same version on the class path ("could not find package scala.annotation.internal", or
scala.deriving for 3.10.0). Since 3.8 the Scala 3 standard library is published as org.scala-lang:scala-library 3.x,
and the 2.13.18 scala-library takes its place. Only a library whose API needs nothing from that standard library, such
as this macro library, still got through. A library that mixes macros for 2.13 users is therefore built by 3.7 or older
(3.3 LTS is the usual choice), and it has to be tested the same way, with a consumer on each side. Which of the two options to choose follows from the audience: cross-building gives each user an
ordinary artifact at the price of two implementations to publish, while mixing publishes once but depends on the reader.
The tests of the guide’s mixing tutorial live in a separate cross-built module on top (example-test, with
-Ytasty-reader added when the Scala version is 2.13), because the tests have to run under both compilers.
After the migration
A port is finished when the project compiles with no migration options, tests pass on Scala 3 without Scala 2.13 in the loop, and the CI pipeline builds with the new compiler. A short checklist:
-
Remove
-source:3.x-migration,-rewrite,-Xsource:3, the temporarycrossScalaVersionsentry, the migration plugin and the conditional compiler-plugin settings. -
Turn
-Xfatal-warnings(or-Werror) back on, and read the Scala 3 options in Compiler Options, Linting & Errors. -
Pick the long-term target: 3.9 LTS for libraries, 3.10 or later for applications, and follow Upgrading Between Scala 3 Versions for later bumps. Each of those upgrades uses the same
-source:3.x-migration -rewriterecipe as step 2. -
Move to the new idioms gradually; none of them is required for the code to work.
implicitdefinitions still compile, and the replacements are explained in Contextual Abstractions Overview, Enums and Optional Braces & Indentation. -
If the project is a library, run MiMa against the last Scala 2 release and decide whether to keep publishing for 2.13.
Everything on this page in one table:
| Tool | Runs on | What it does |
|---|---|---|
|
Scala 2.13.14 or newer |
Reports Scala 3 migration messages, adopts selected Scala 3 behaviors, and fixes many warnings in place |
|
Scala 3 compiler |
Accepts removed constructs with a warning, rewrites them, and explains the rest |
|
sbt 1.5 or newer, Scala 2.13 modules without macros |
Guides the whole port of a module, one command at a time |
Cross-building ( |
sbt and other build tools |
Compiles and tests the same sources with both compilers while porting by hand |
|
sbt |
Uses a Scala 2.13 library from Scala 3 when no Scala 3 version exists |
Scalafix ( |
sbt-scalafix, Scala 2.13 |
Syntactic and semantic rewrites; the external scala-rewrites rules did not run with Scalafix 0.14.9 |
Cross-building or mixing macros |
Scala 2.13 and Scala 3 |
Gives a macro library a Scala 3 implementation, as two artifacts or as one |
References
-
scala3-migration-example, the guide’s practice project