sbt & Build Tools
|
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, the current Scala Next line) and sbt 2.0.10 on JDK 26. All the sbt commands and build files below were run on 2026-10-15 and 2026-10-16 with sbt 2.0.10, which is the newest stable 2.0.x at the check date; the sbt 1.x forms are quoted from the documentation and labelled as such. Scala 3.10 and 3.9 LTS both need JDK 17 or later, and so does sbt 2 itself. Build definitions of sbt 2 are written in Scala 3 (3.8.4 for sbt 2.0.10), whatever Scala version your project uses.
sbt is the build tool the Scala ecosystem grew up with: it compiles, tests, packages and publishes, resolves libraries from Maven repositories, and is itself written in Scala. For one file or a small tool, the Scala CLI is shorter; this page is for projects with several modules, plugins and cross-building. The Java build tools, Maven and Gradle, are covered in depth on Java Build & Tooling; this section only says how Scala fits into them (see the section on Mill, Maven and Gradle).
How the examples were run: sbt is normally started by the sbt runner script that Coursier, SDKMAN! or Homebrew installs, which launches the sbtn native client. Here the
sbt 2.0.10 launcher JAR (org.scala-sbt:sbt-launch) was fetched from Maven Central with Scala CLI and started with java -jar, restricted to Maven Central as the only repository, in batch mode.
In batch mode you pass the commands as arguments (sbt compile run); they are the same commands you would type into the sbt shell.
A first build
An sbt project needs two files: project/build.properties fixes the sbt version, and build.sbt describes the build.
sbt.version=2.0.10
ThisBuild / scalaVersion := "3.10.0"
ThisBuild / organization := "com.example"
ThisBuild / version := "0.1.0-SNAPSHOT"
lazy val root = (project in file("."))
.settings(
name := "trailhead",
scalacOptions ++= Seq("-deprecation", "-feature"),
libraryDependencies += "org.scala-lang" %% "toolkit" % "0.9.2",
libraryDependencies += "org.scala-lang" %% "toolkit-test" % "0.9.2" % Test
)
Add a main method and a test next to it, under src/main/scala and src/test/scala:
// src/main/scala/Main.scala
@main def trailhead(): Unit =
println(s"Hello from sbt, Scala ${scala.util.Properties.versionNumberString}")
// src/test/scala/TrailSuite.scala
class TrailSuite extends munit.FunSuite:
test("sum"):
assertEquals(List(1, 2, 3).sum, 6)
Then run sbt compile run test (three commands in one invocation). The output of the real run:
[info] welcome to sbt 2.0.10 (Oracle Corporation Java 26)
[info] loading project definition from ./project
[info] set current project to trailhead (in build file:./)
[info] Executing in batch mode. For better performance use sbt's shell
[info] compiling 1 Scala source to ./target/out/jvm/scala-3.10.0/trailhead/classes ...
[info] done compiling
[success] elapsed time: 9 s, cache 30%, 3 disk cache hits, 7 onsite tasks
[info] running trailhead
Hello from sbt, Scala 3.10.0
[success] elapsed time: 0 s, cache 52%, 10 disk cache hits, 9 onsite tasks
[info] compiling 1 Scala source to ./target/out/jvm/scala-3.10.0/trailhead/test-classes ...
[info] done compiling
TrailSuite:
+ sum 0.006s
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
[success] elapsed time: 2 s, cache 62%, 18 disk cache hits, 11 onsite tasks
(Paths are shortened to . and a few lines were trimmed.) Three things are visible already. The first run downloaded the Scala compiler and the Toolkit from Maven Central. The outputs go to
target/out/jvm/scala-3.10.0/trailhead/, a layout new in sbt 2. And every task line ends with cache statistics, because sbt 2 caches the results of all tasks. The Scala 3 Book’s equivalent is
a one-line scalaVersion := "3.10.0" plus a @main method in any .scala file in the root; that also works, because sbt compiles any Scala file in the base directory.
If you want to see the effective value of any setting or task, show prints it (the > prompt marks what you type in the sbt shell):
> show scalaVersion
[info] 3.10.0
> show libraryDependencies
[info] * org.scala-lang:scala3-library:3.10.0
[info] * org.scala-lang:toolkit:0.9.2
[info] * org.scala-lang:toolkit-test:0.9.2:test
The directory layout
sbt follows a Maven-like convention, so every project looks the same. The tree below shows a two-module build (core and app) like the one in the multi-project section.
-
build.sbtis the build definition, written in Scala. Every.sbtfile in the base directory is part of it. -
project/holds the build’s own configuration:build.propertieswith the sbt version,plugins.sbtwith plugins, and optional.scalaor.sbtfiles with build code. The build that sbt compiles fromproject/is itself an sbt project. -
src/main/{scala,java,resources}andsrc/test/{scala,java,resources}are the sources, Java files included; a mixed project compiles both. There are alsoscala-3directories for sources that only a given Scala line should see. -
lib/in the base directory is for unmanaged JAR dependencies (a fallback; prefer managed ones). -
target/contains generated files and is not versioned. In sbt 1.x it wastarget/scala-3.x/classesinside each project; in sbt 2.x the target is one directory with a subdirectory per platform, Scala version and subproject, for exampletarget/out/jvm/scala-3.10.0/trailhead-corefor a subproject namedtrailhead-core(found withshow core/target). -
.jvmoptsand.sbtoptsin the root set JVM and sbt options for everyone who builds the project.
build.sbt: settings, tasks and scopes
A build is a set of settings, which are values (name := "x"), and tasks, which compute a value and may have side effects (compile, test). Both are keys. The operators:
| Operator | Meaning |
|---|---|
|
Assign the value of a setting or task. |
|
Append one element to a sequence setting, such as |
|
Append a whole sequence. |
|
Transform the existing value with a function. |
A key is addressed by a scope: project / Configuration / task / key, using slashes. core / Test / scalacOptions is the compiler options of the core project in the Test configuration.
The old sbt 0.13 syntax with a colon, such as test:compile, no longer exists in sbt 2: use Test/compile.
The dependency operators build a coordinate, and the number of percent signs matters:
| Form | Resolves to |
|---|---|
|
A plain Java artifact with exactly that name. |
|
|
|
sbt 1.x only: appends the platform suffix too ( |
|
Use a Scala 2.13 artifact from a Scala 3 project (the Scala 3 compiler can read 2.13 binaries); the reverse is |
(The last two rows and the platform behaviour are quoted from the sbt migration guide and not run here, because they need the Scala.js plugin.)
sbt 2 changes how bare settings work. A setting written directly in build.sbt, without being inside .settings(...), is a common setting: it is added to every subproject, where in sbt 1.x it applied only to the root project.
That removes most uses of ThisBuild, and it also means that a bare name := "root" names every subproject "root", so keep names inside .settings. A real run of a build with common settings:
scalaVersion := "3.10.0"
organization := "com.example"
version := "0.1.0-SNAPSHOT"
scalacOptions ++= Seq("-deprecation", "-feature")
lazy val core = project
.settings(libraryDependencies += "com.lihaoyi" %% "os-lib" % "0.11.8")
lazy val app = project
.dependsOn(core)
lazy val hi = taskKey[String]("Says hi with the project name")
hi := name.value + "!"
> show hi
[info] core / hi
[info] core!
[info] app / hi
[info] app!
[info] hi
[info] common-root!
In sbt 1.x the same hi task would have returned the root project’s name for every subproject (the "dynamic dispatch" problem that common settings fix). The plain name of the
unnamed root project, common-root here, comes from the directory name common.
A task is declared with taskKey and defined with :=; name.value reads another key. Tasks that read and write files have to declare their outputs so that the cache can restore them
(see the sbt 2 caching reference); most builds never write such a task.
Dependencies and resolution
libraryDependencies is a Seq[ModuleID]. A % Test suffix limits the dependency to the test classpath, as with toolkit-test above. sbt 2 resolves with Coursier only (the useCoursier switch is gone), from Maven Central unless you add resolvers. The
dependencyTree task prints the resolved graph:
> app/dependencyTree
com.example:app_3:0.1.0-SNAPSHOT
+-com.example:core_3:0.1.0-SNAPSHOT
| +-com.lihaoyi:os-lib_3:0.11.8
| | +-com.lihaoyi:geny_3:1.1.1
| | | +-org.scala-lang:scala3-library_3:3.10.0 [S]
| | | +-org.scala-lang:scala3-library_3:3.3.1 (evicted by: 3.10.0)
| | |
| | +-com.lihaoyi:os-zip:0.11.8
| | +-org.scala-lang:scala3-library_3:3.10.0 [S]
| | +-org.scala-lang:scala3-library_3:3.3.5 (evicted by: 3.10.0)
| |
| +-org.scala-lang:scala3-library_3:3.10.0 [S]
|
+-org.scala-lang:scala3-library_3:3.10.0 [S]
The [S] marks a Scala library, and "evicted by" shows version conflicts: geny and os-lib were built against older Scala 3 libraries, but the project’s scalaVersion wins, because the Scala 3 library is always the one of the
compiler in use. The Test configuration enforces eviction errors in sbt 2.
Common tasks
| Command | What it does |
|---|---|
|
Compile main or test sources incrementally with the Zinc incremental compiler. |
|
Run the discovered main class (a |
|
Run the tests. In sbt 2 this is incremental: tests that passed and whose inputs did not change are not run again. |
|
Start the Scala REPL with the project on the classpath. In sbt 2 it runs in the client and does not block the server. |
|
Build the JAR of the project; the |
|
Generate Scaladoc (see Scaladoc). |
|
Install into the local repository, or upload to a remote one. Not run here, because publishing needs a repository and credentials. |
|
Delete the build outputs, or reload the build definition. In sbt 2 changes to |
|
Discover what exists. |
|
Prefix with |
|
Print the dependency tree; write a lock file of the resolved dependencies. |
A real test-caching example. After core/test passes, a second core/test finds nothing to do, and it says which task it replaced:
> core/test
[info] Passed: Total 0, Failed 0, Errors 0, Passed 0
[info] No tests to run for core / Test / testQuick
This is the incremental test of sbt 2.0. Changing the code under test makes the test run again, and a failing assertion (here the expected name b2 was typed wrongly on purpose) prints the usual MUnit report and fails the command:
[error] Failed: Total 1, Failed 1, Errors 0, Passed 0
[error] Failed tests:
[error] TrailSuite
[error] (core / Test / testQuick) sbt.TestsFailedException: Tests unsuccessful
testFull always runs all the tests, and core/testOnly TrailSuite runs one suite:
> core/testOnly TrailSuite
TrailSuite:
+ longest 0.046s
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
sbt 2 knows MUnit, ScalaTest, specs2, ScalaCheck, JUnit (through junit-interface), ZIO Test, Weaver and Hedgehog by default. show core/Test/testFrameworks prints the list.
Multi-project builds
A repository with several modules defines one project per module, links them with dependsOn, and puts them under a root that aggregate`s them, so that a command on the root runs on all of them. Each subproject lives in its own directory, with its own `src/. A real two-module build, with a shared
MUnit dependency written once as a common setting:
scalaVersion := "3.10.0"
crossScalaVersions := Seq("3.10.0", "3.9.0")
organization := "com.example"
version := "0.1.0-SNAPSHOT"
scalacOptions ++= Seq("-deprecation", "-feature")
libraryDependencies += "org.scalameta" %% "munit" % "1.2.1" % Test
lazy val core = project
.settings(name := "trailhead-core")
lazy val app = project
.dependsOn(core)
.settings(
name := "trailhead-app",
Compile / mainClass := Some("hike")
)
lazy val root = (project in file("."))
.aggregate(core, app)
.settings(
name := "trailhead",
publish / skip := true
)
project without in file(...) uses the value’s name as the directory (core/, app/). dependsOn(core) puts core on the classpath of app and orders the compilation; the dependency of app on core is also in
the published POM. aggregate has no effect on classpaths: it only makes tasks run on the listed projects. The sources:
// core/src/main/scala/Trail.scala
package trailhead.core
case class Trail(name: String, km: Double)
def longest(trails: List[Trail]): Option[Trail] = trails.maxByOption(_.km)
// app/src/main/scala/Main.scala
import trailhead.core.*
@main def hike(): Unit =
println(longest(List(Trail("Pine Loop", 4.2), Trail("Ridge", 9.5))))
> projects
[info] In file:./
[info] app
[info] core
[info] * root
> app/run
[info] running hike
Some(Trail(Ridge,9.5))
The asterisk marks the current project. With sbt 2.0.10, root/run fails with no main class detected, because the root project has no sources of its own: select app/run.
A pitfall that bit the first version of this example: the main method was called trailhead while the package was also trailhead, and the compiler reported
-- [E100] Syntax Error: ./app/src/main/scala/Main.scala:1:7
1 | import trailhead.core.*
| ^^^^^^^^^
| method trailhead must be called with () argument
Do not give a top-level method the same name as a package you import from. The @main method became hike. sbt 2 also lets you select subprojects with a query: core.../compile ran the task on every project whose id starts with core (it compiled core here), and, as documented, ...@scalaBinaryVersion=3/test selects projects by a setting value (that form was not run here).
Cross-building
A library must often be built for several Scala versions. Set crossScalaVersions and prefix a command with + to run it once per version, or switch the current version with ++:
> +core/compile
[info] Setting Scala version to 3.10.0 on 3 projects.
...
[info] Setting Scala version to 3.9.0 on 3 projects.
...
> ++3.9.0 core/test
[info] Setting Scala version to 3.9.0 on 3 projects.
[info] compiling 1 Scala source to ./target/out/jvm/scala-3.9.0/trailhead-core/test-classes ...
TrailSuite:
+ longest 0.006s
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
> show scalaVersion
[info] 3.9.0
(The ... lines stand for the compile output, which was trimmed.) The setting scalaVersion stays changed for the rest of the session until reload. For a library, which versions to target is explained on
Versions, Compatibility & JDKs. If a library must also exist for Scala.js and Scala Native, or for several Scala versions at once, sbt 2 includes the project matrix
that was a plugin (sbt-projectmatrix) in sbt 1.x: you declare each platform and Scala version row in one definition,
lazy val core = (projectMatrix in file("core"))
.settings(name := "core")
.jvmPlatform(scalaVersions = Seq("3.10.0", "3.9.0"))
and each row becomes a normal subproject. The definition above loaded and listed a core project on sbt 2.0.10; building the individual rows was not tried here, and the Scala.js and Scala Native rows (.jsPlatform(...), .nativePlatform(...)) need their plugins (sbt-scalajs 1.22.0, sbt-scala-native 0.5.11 per the sbt 2 announcement) and were not run here.
Plugins
Plugins are added in project/plugins.sbt, and in sbt 2 they are published with the _sbt2_3 suffix. The plugins that most Scala builds use (written as in their documentation, not run here because they would need further downloads):
// project/plugins.sbt (not run here)
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.1") // fat JARs
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.22.0") // Scala.js
addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.11") // Scala Native
The versions of sbt-assembly (2.3.1), Scala.js (1.22.0) and Scala Native (0.5.11) come from the sbt 2 announcement. Check that a plugin has been published for sbt 2 before upgrading a build: plugins built only for sbt 1.x do not load.
What changed in sbt 2.0
The sbt 2.0 stable release is dated 2026-06-14 (Maven Central) and announced on 2026-06-29. The headline changes, with the practical consequences:
| Change | Consequence |
|---|---|
Build definitions use Scala 3 (3.8.4) |
|
Client-server by default |
The |
Common settings |
Bare settings apply to all subprojects; use |
Everything is cached |
All tasks cache their results on disk, with an optional Bazel-compatible remote cache. A task result type needs a JSON format; wrap the body of an uncacheable task in |
Incremental |
|
One |
|
Project matrix is built in |
|
Slash syntax only |
|
Client-side |
They run in a fresh JVM forked by the client, so the sbt server is not blocked. |
Starting with sbt 2.0.4 the forked run uses the build’s working directory, not the subproject’s. The global base directory follows the XDG standard ($XDG_CONFIG_HOME/sbt/2 or $HOME/.config/sbt/2, %LOCALAPPDATA%/sbt/2 on Windows).
Mill, Maven and Gradle
Mill is a Scala-first build tool whose build file, build.mill, is Scala code in which modules are objects. It is the other target of scala export. The file below is the one that Scala CLI generated for the Scala CLI example project, with the machine-specific
repository lines removed; it is shown as generated and was not run here, because that would download the Mill launcher and its dependencies:
import mill._
import mill.scalalib._
object project extends ScalaModule {
def scalaVersion = "3.10.0"
def scalacOptions = super.scalacOptions() ++ Seq("-deprecation", "-Wunused:imports")
def mvnDeps = super.mvnDeps() ++ Seq(
mvn"com.lihaoyi::os-lib:0.11.8"
)
object test extends ScalaTests {
def mvnDeps = super.mvnDeps() ++ Seq(
mvn"org.scala-lang::toolkit-test::0.9.2,toolkit",
mvn"com.lihaoyi::os-lib:0.11.8"
)
def testFramework = "munit.Framework"
}
}
Source: Mill documentation
Mill’s documentation is at https://mill-build.org/. Maven builds Scala with the scala-maven-plugin: the Scala library is a normal dependency, _3 artifacts are spelled out in the artifact id, and the plugin
compiles src/main/scala. The official tutorial is Scala with Maven. Gradle uses the scala plugin and builds src/main/scala the same way. Maven and Gradle for Scala projects in depth belong to a separate
page of this documentation (tracked as issue #300), and neither was run here. They are a reasonable choice when a Scala module lives inside a larger Java build; sbt and Mill are the usual choice for Scala-only projects, because
they understand cross-building and Scala versions natively.
Where the books are outdated
-
The Scala 3 Book shows
sbt.version=1.10.11and a console that says "sbt 1.5.4"; for a new project usesbt.version=2.0.10(or the newest 2.0.x). ItsHello.scalaexample still works, because sbt compiles Scala files in the root directory. -
Older tutorials start with
sbt new scala/scala3.g8, a template that is not needed any more to create the two files above. Scala CLI can doscala --power new(a Giter8 template; not run here) for the same purpose. -
ThisBuild / scalaVersionandlazy val root = (project in file("."))withsettings(...)are fine in sbt 2, but bare common settings replace most uses ofThisBuild. -
%%%andsbt-crossprojectare the sbt 1.x way to cross-build for Scala.js and Native; in sbt 2, useprojectMatrixand%%. -
test:compile,it:test, and theIntegrationTestconfiguration are gone; so isuseCoursier := false. -
Scala 2 build files that depend on
scalaVersion := "2.13.x"still work: sbt 2 builds Scala 2 and 3 projects, whatever it uses for its own build definition.