Setting Up a Scala Project: sbt, build.sbt, and Scala CLI Explained
A single scala Hello.scala command is enough to learn syntax, but no real Scala codebase runs that way. Real projects have a directory structure sbt expects, a build.sbt file declaring dependencies and compiler settings, and a test suite wired into the build. This guide sets up a genuine multi-file sbt project and explains exactly what each configuration line does โ not just what to type.
Installing Scala and sbt
# Recommended: the Coursier-based installer, handles Scala, sbt, and scala-cli togethercurl -fL https://github.com/coursier/coursier/releases/latest/download/cs-x86_64-apple-darwin.gz | gzip -d > cschmod +x cs && ./cs setup
scala -versionsbt --versionCoursier (cs) is the modern, recommended way to install the whole Scala toolchain at once โ it manages JVM versions, the Scala compiler, and sbt together, avoiding the version-mismatch headaches that came from separately installed pieces in Scalaโs earlier tooling era. SDKMAN is a common alternative, particularly for teams already using it to manage JVM versions for Java projects.
The Standard sbt Project Layout
my-scala-project/โโโ build.sbtโโโ project/โ โโโ build.propertiesโ โโโ plugins.sbtโโโ src/โ โโโ main/โ โ โโโ scala/โ โ โโโ com/example/Main.scalaโ โโโ test/โ โโโ scala/โ โโโ com/example/MainSpec.scalaโโโ target/ โ compiled output, gitignoredThis layout isnโt arbitrary โ itโs the โMaven standard directory layoutโ convention that sbt inherited deliberately, specifically so JVM tooling built for Java projects (many IDE plugins, some CI conventions) works against Scala projects with zero extra configuration. src/main/scala holds application code; src/test/scala holds tests; target/ is entirely generated and should never be committed.
build.sbt, Explained Line by Line
ThisBuild / scalaVersion := "3.3.1"ThisBuild / organization := "com.example"
lazy val root = (project in file(".")) .settings( name := "my-scala-project", version := "0.1.0-SNAPSHOT", libraryDependencies ++= Seq( "org.scalatest" %% "scalatest" % "3.2.17" % Test, "com.typesafe" % "config" % "1.4.2" ) )ThisBuild / scalaVersion := "3.3.1" โ sets the Scala compiler version for the entire build (the ThisBuild scope applies it to every project/module in a multi-module build, not just the current one). Pinning this explicitly matters because Scalaโs compiler versions arenโt always drop-in compatible with each other, especially across the Scala 2/Scala 3 boundary.
lazy val root = (project in file(".")) โ declares a project rooted at the current directory. lazy val matters here specifically because sbtโs build definition has its own dependency-resolution order, and lazy ensures the project value is only evaluated once its dependencies (other lazy val project definitions, in a multi-module build) are ready โ a genuine val here can produce confusing initialization-order errors in anything beyond a single-module project.
libraryDependencies ++= Seq(...) โ the dependency list. The %% operator (double percent) versus % (single) is a real, easy-to-miss distinction:
"org.scalatest" %% "scalatest" % "3.2.17" % Test // %% appends the Scala binary version automatically"com.typesafe" % "config" % "1.4.2" // % โ a plain Java library, no Scala version suffix needed%% tells sbt to append the projectโs Scala binary version to the artifact name automatically โ "org.scalatest" %% "scalatest" % "3.2.17" actually resolves to the artifact scalatest_3 (for Scala 3) or scalatest_2.13 (for Scala 2.13), because Scala libraries are frequently published separately per major/minor Scala version due to binary incompatibility between them. Forgetting %% for a Scala library, or including it for a pure-Java library (which has no Scala-version-suffixed variants at all), is one of the most common build.sbt mistakes โ the error message (โunresolved dependencyโ) doesnโt always make which mistake occurred obvious at a glance.
% Test โ scopes the dependency to the test configuration only, meaning itโs available in src/test/scala but not compiled into the actual production artifact โ the same idea as devDependencies in an npm package.json, or a test-scoped Maven dependency.
project/build.properties and project/plugins.sbt
sbt.version=1.9.7addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2")build.properties pins the exact sbt version used to build the project โ without it, developers on different machines (or CI, running a different default) could silently use different sbt versions, occasionally producing different build behavior. plugins.sbt lives in the project/ directory specifically because sbtโs own build definition is itself a Scala project one level up โ plugins augment sbt itself (adding commands like formatting or linting), which is why theyโre declared in a nested build rather than the top-level build.sbt.
Running Common sbt Commands
sbt compile # compile main sources onlysbt test # compile and run the test suitesbt run # compile and run the app (if it has a main entry point)sbt console # start a REPL with the project's classpath and dependencies loadedsbt clean # delete the target/ directorysbt ~compile # the ~ prefix watches files and recompiles automatically on save# Interactive sbt shell โ starting sbt once and running multiple commands avoids repeated JVM startup costsbt> compile> test> runThe ~ prefix on any command (~compile, ~test) enables watch mode โ sbt recompiles (or reruns tests) automatically every time a source file changes, which is the standard tight development loop for Scala, roughly equivalent to nodemon or a file-watching test runner in other ecosystems. Starting sbt once as an interactive shell and running commands inside it avoids paying sbtโs JVM startup cost (historically one of its most-criticized characteristics) on every single command.
Multi-Module Projects
// build.sbt for a project with a shared library and a separate applicationlazy val core = (project in file("core")) .settings( name := "core", libraryDependencies += "org.typelevel" %% "cats-core" % "2.10.0" )
lazy val app = (project in file("app")) .dependsOn(core) .settings( name := "app" ).dependsOn(core) makes the app module able to import and use everything core exposes โ this is how real Scala projects (and Spark applications with shared utility code, in particular) split a large codebase into independently compilable, independently testable modules while still sharing code cleanly, without duplicating it or publishing an internal library to a package repository just to share it internally.
Scala CLI โ The Lightweight Alternative
# A single-file script, no project structure needed at allscala-cli run Hello.scala
# Adding a dependency inline, with no build.sbtscala-cli run Hello.scala --dependency "com.typesafe:config:1.4.2"//> using scala "3.3.1"//> using dep "com.typesafe:config:1.4.2"
@main def hello(): Unit = println("Hello with a dependency, no build.sbt needed")The //> using directives at the top of a file are scala-cliโs own lightweight configuration mechanism โ dependencies, Scala version, and compiler flags declared directly in the source file, avoiding a separate build file entirely for scripts and small tools. This genuinely is the better choice for a one-off script, a coding exercise, or quickly testing a libraryโs API โ reach for full sbt once the project has more than a handful of files, needs a real test suite wired into CI, or needs multi-module structure.
A Realistic build.sbt for a Small Application
ThisBuild / scalaVersion := "3.3.1"
lazy val root = (project in file(".")) .settings( name := "order-processor", version := "0.1.0", libraryDependencies ++= Seq( "org.scalatest" %% "scalatest" % "3.2.17" % Test, "com.typesafe" % "config" % "1.4.2", "ch.qos.logback" % "logback-classic" % "1.4.14" ), scalacOptions ++= Seq( "-deprecation", // warn on use of deprecated APIs "-feature", // warn when using a language feature that needs an explicit import "-unchecked" // warn on unchecked type erasure issues ) )scalacOptions configures compiler warning/error behavior โ -deprecation and -feature in particular surface real, actionable warnings that are easy to silently ignore without them explicitly enabled, and enabling them from the start of a project (rather than retrofitting them onto a large codebase later, where they can produce an overwhelming number of warnings at once) is standard practice for teams that care about code health.
IDE Setup: Metals
# VS Code: install the "Scala (Metals)" extension, then open the project folder# Metals detects build.sbt automatically and imports the build on first openMetals is the language server providing autocomplete, go-to-definition, inline type information, and real-time error checking for Scala across VS Code, Neovim, and other editors supporting the Language Server Protocol. On first opening a project, Metals runs an โimport buildโ step โ it asks sbt (or Mill, another less common Scala build tool) to report the projectโs exact classpath and dependencies, which is why the very first open of a new or unfamiliar Scala project is often noticeably slower than subsequent ones: that import step is being cached for next time. IntelliJ IDEAโs Scala plugin is the other mainstream option, with its own independent build-import mechanism rather than Metals โ either is a reasonable choice, and switching between them mid-project is uncommon but not harmful.
Common Setup Mistakes
| Symptom | Likely cause |
|---|---|
| โunresolved dependencyโ for a Scala library | Missing %% โ the Scala binary version suffix wasnโt appended automatically |
sbt uses a different Scala version than expected | build.properties or scalaVersion mismatch between whatโs declared and whatโs actually installed |
| Test dependency accidentally bundled into the production artifact | Missing % Test scope on a dependency that should only be available for tests |
sbt compile is extremely slow on every single change | Not using ~compile (watch mode) or the interactive shell โ restarting sbt fresh for every command re-pays JVM startup cost |
| A multi-module project canโt find code from another module | Missing .dependsOn(otherModule) in the dependent projectโs settings |
Frequently Asked Questions
Do I need sbt for every Scala project? No โ scala-cli handles single-file scripts and small tools well without any build file at all. Reach for sbt once you need multi-module structure, a real dependency management story with version conflict resolution, or CI integration that expects a standard build tool.
Why is sbtโs startup so slow compared to other build tools? sbt itself runs on the JVM and has historically had meaningful cold-start overhead loading its own build definition. Using the interactive shell (starting sbt once, running many commands inside it) avoids re-paying that cost repeatedly, which is the standard mitigation rather than something thatโs been fully eliminated.
Whatโs the difference between % and %% in a dependency declaration? %% automatically appends the projectโs Scala binary version to the library name, needed for any library thatโs actually written in Scala and published per-Scala-version. % is for plain Java libraries (or the rare Scala library that deliberately publishes a version-independent artifact), which have no such per-version variants.
Should target/ be committed to git? No โ itโs entirely generated build output (compiled classes, downloaded dependency caches in some configurations), and should always be in .gitignore, the same as node_modules or a Python venv.
Can I mix Scala 2 and Scala 3 code in one project? Not directly compiled together in the same module โ but Scala 3โs compiler has a Scala 2.13 compatibility mode, and multi-module builds can mix modules on different Scala versions if their dependency relationships allow it. For most new projects, standardizing on Scala 3 throughout is simpler and is what this series assumes going forward.
Whatโs Next
You now have a real, working sbt project structure โ not just a single-file script โ with dependency management and a repeatable build. Next: Variables and Basic Types in Scala, where we cover val vs var, Scalaโs type inference in depth, and why Scala treats if, for, and blocks as expressions rather than statements.