Metals is the language server behind Scala support in VS Code, Neovim, Emacs, Sublime Text, Helix, Zed and other editors. Most developers install the extension, click "Import build", and carry on until something goes wrong: completions that know nothing about a new dependency, find references that misses half the call sites, an import that runs for ten minutes, or a red squiggle that sbt on the command line does not reproduce.
Each of those symptoms comes from a specific part of Metals. This article takes it apart: the editor client, the Metals server, the build server it talks to, the presentation compiler that answers as you type, and the SemanticDB and source index behind navigation. With that model you can tell which part is misbehaving and fix it in minutes instead of reinstalling the extension. It ends with a worked example on a large sbt monorepo, a failure table and a checklist.
The moving parts
Metals sits between two protocols. The editor speaks the Language Server Protocol (LSP): JSON-RPC messages such as "completion at line 40, column 12" or "find references of this symbol". Metals does not compile your project itself; it speaks the Build Server Protocol (BSP) to a build server that knows your modules, dependencies and compiler options, and that compiles on request.
Inside Metals are three knowledge sources. The presentation compiler is an interactive mode of the Scala compiler that type-checks the file you are editing, including unsaved text, and answers completions, hover and signature help. SemanticDB files are written by a compiler plugin during a real compile and record every symbol occurrence in every compiled file; they power find references, rename and workspace-wide navigation. The source index is Metals' own fast parse of your sources and dependency source jars, so go to definition works into a library without compiling it.
Almost every Metals complaint maps to one of these. Stale references mean SemanticDB is out of date because a compile failed. Wrong completions mean the presentation compiler has the wrong classpath or Scala version. A slow import is the build server exporting the build. Knowing which is which is most of the skill.
What Import build actually does
When you open a folder containing build.sbt, Metals offers to import it. With the default Bloop build server it writes project/metals.sbt to add the sbt-bloop plugin, starts sbt, and runs the Bloop export, which writes one JSON file per module into .bloop/: source directories, the full resolved classpath, scalac options, the Scala version and the Java home. It asks for source jars of every dependency so navigation can open library code.
Metals then connects to the Bloop server over BSP, reads the module list, and asks Bloop to compile. Bloop runs the compiler with the SemanticDB plugin enabled so that navigation data is produced as a side effect. Meanwhile Metals indexes workspace sources and dependency source jars into its database under .metals/.
The expensive steps are dependency resolution and the first full compile. Docs describe imports taking anywhere from ten seconds to fifteen minutes depending on build size and whether dependencies are already cached. Re-import is needed only when the build definition changes: a new dependency, module or compiler flag. Metals watches the build files and prompts you; if you dismiss the prompt, completions keep using the old classpath, which is the most common cause of "it compiles in sbt but Metals says the import does not exist".
my-service/
build.sbt
project/
build.properties # sbt version, read by the launcher
metals.sbt # written by Metals: adds the sbt-bloop plugin (do not commit)
.bloop/ # one JSON file per module, exported from sbt (do not commit)
.bsp/sbt.json # only if you switched to sbt as the build server
.metals/ # Metals database, logs, explained diagnostics (do not commit)
.scalafmt.conf # commit this: formatter version and style
.jvmopts # JVM options for running and debugging your code
Choosing a build server
Bloop is the default and the Metals docs recommend staying on it unless you need a feature another server offers. It is a long-running compile server shared across workspaces, keeps compilers warm, and compiles in parallel. The cost is a second copy of your build model: Bloop compiles from its exported JSON, so custom sbt tasks, source generators that run only inside sbt, and settings computed at load time can differ from what sbt does.
sbt's own BSP server, available since sbt 1.4.1, removes that duplication: sbt compiles exactly as it does on the command line and source generators run. The price is sbt's heavier resident process and, on large builds, slower incremental feedback. Mill and Scala CLI both implement BSP as well, and scala-cli setup-ide . writes the connection file for a Scala CLI project.
| Build server | Strength | Cost | Pick it when |
|---|---|---|---|
| Bloop (default) | Fast, warm compilers, shared across projects | Exported build can drift from sbt | Most sbt, Maven and Gradle projects |
| sbt BSP | Compiles exactly like sbt; source generators run | Heavier process, slower on big builds | Heavy code generation or custom tasks |
| Mill BSP | Native Mill semantics | Mill-specific setup | The build is already Mill |
| Scala CLI | Zero-config scripts and small apps | Not for large multi-module builds | Scripts, single-module tools, teaching |
# Default: Metals exports the build to Bloop. The manual equivalent, useful in CI images
# or when the automatic import keeps failing, is:
sbt -Dbloop.export-jar-classifiers=sources bloopInstall
# Use sbt itself as the build server instead (sbt 1.4.1 or later):
# Command palette -> "metals.generate-bsp-config" (writes .bsp/sbt.json)
# Command palette -> "metals.bsp-switch" (pick sbt or bloop)
# Go back to Bloop: metals.bsp-switch -> bloop, or metals.reset-choice then metals.build-restart
# Scala CLI projects and single-file scripts
scala-cli setup-ide .
Presentation compiler, SemanticDB and the index
The presentation compiler runs inside the Metals JVM, one instance per Scala version in your build, and reuses the module's classpath from the build server. Because it type-checks the buffer you are typing into, it can complete members on code that has never been saved. It knows only what is on the classpath: if a dependency was added to build.sbt but the build was not re-imported, completions will not see it, however many times you save.
SemanticDB is produced only by a successful compile of the module. If one file has a type error, the build server may not emit fresh SemanticDB for that module, and references, rename and call hierarchy reflect the last good compile. That is why find references can miss a call you just wrote: it will appear after the next clean compile. Rename is the dangerous one, since it edits only the occurrences it knows about. Fix compile errors before renaming.
The source index is independent of compilation, so go to definition into a dependency works even when your own code does not compile. That is also why go to definition can succeed while hover says a symbol is unknown: two different subsystems answered.
JDKs, settings and memory
Two JDKs are involved and people conflate them. The Metals server itself runs on a JDK the extension picks: the docs require JDK 11 or later and default to 17. Your project is compiled and run with the JDK set in javaHome, which should match what CI uses. Mixing them up produces errors such as class-file version mismatches when a library needs a newer Java than the one compiling your code.
JVM options for running tests and apps from the editor come from .jvmopts and .test-jvmopts in the workspace. The serverVersion setting pins a Metals release or tries a snapshot; leave it unset unless you are testing a fix. On large workspaces, exclude .bloop, .metals and target from the editor's file watcher so thousands of generated files do not flood it.
// .vscode/settings.json (workspace settings, safe to commit)
{
"metals.javaHome": "/usr/lib/jvm/temurin-21", // JDK used to compile and run the project
"metals.shutdownBloopOnEditorClose": true, // stop Bloop when the window closes
"files.watcherExclude": {
"**/.bloop": true,
"**/.metals": true,
"**/target": true
}
}Commit editor-agnostic files that define behaviour, such as .scalafmt.conf and .scalafix.conf. Do not commit .metals/, .bloop/, .scala-build/ or metals.sbt; they are machine-specific and regenerated on import.
Worksheets, formatting, Scalafix and tests
A file ending in .worksheet.sc is evaluated on every save, with each statement's value shown inline. A worksheet placed inside a module's source directory sees that module's classpath, which makes it a quick way to poke at your own APIs without writing a test.
// src/main/scala/pricing/explore.worksheet.sc
// Every top-level statement is evaluated on save; results appear as inline decorations.
import pricing.*
val basket = List(Item("book", 1200), Item("pen", 150), Item("pen", 150))
val total = Pricing.total(basket) // total: Long = 1500
val promo = Pricing.applyPromo(basket, "PENS2") // promo: Long = 1350
basket.groupMapReduce(_.sku)(_.cents)(_ + _) // Map(book -> 1200, pen -> 300)Formatting uses Scalafmt configured by .scalafmt.conf; Metals offers to create one and downloads the Scalafmt version the file names, so everyone formats identically. Organize imports and some code actions run Scalafix rules; see Scalafix in depth for writing rules. Test suites for JUnit, MUnit, ScalaTest and Weaver appear in the test explorer and as run and debug code lenses, launched through the build server with a debug adapter.
When a compiler error is terse, Metals can surface the compiler's -explain output; recent releases write explained diagnostics under .metals/explained-diagnostics and link them from the error.
Metals for AI assistants: the MCP server
Coding agents are much better at Scala when they can ask a compiler instead of guessing. Metals now exposes its knowledge through the Model Context Protocol. Release 1.6.6 (March 2026) added a standalone MCP server that runs without an editor, pointed at a workspace, and can generate the client configuration for supported tools.
Treat it like any other Metals client: it needs an imported build to answer accurately, and it shares the same failure modes. If the agent reports that a symbol does not exist, check the import before blaming the model.
# Run the Metals MCP server without an editor, against one workspace
metals-mcp --workspace ~/src/my-service
# Generate the MCP client configuration for a supported editor or agent;
# see the Metals docs for the accepted client names
metals-mcp --workspace ~/src/my-service --client <client-name>
Worked example: a 60-module sbt monorepo
A team has 60 sbt modules, a protobuf code generator in several modules, and Scala 2.13 with a few Scala 3 modules. Complaints: import takes twelve minutes, generated classes show as missing in the editor, and find references is unreliable.
Step one is measurement. Run Doctor shows each module's Scala version, whether SemanticDB is enabled, and the build server; the Metals log in .metals/metals.log shows how long export and first compile took. Here export takes three minutes and the first compile nine, so compile is the target. Two Scala versions also mean two presentation compilers in memory.
Step two fixes generated code. Bloop compiles from the exported model and does not run sbt tasks itself, and the protobuf generator is an sbt source generator, so after a .proto edit the editor keeps seeing the old generated classes until the build is exported again. The team switches those developers to sbt BSP with metals.bsp-switch, accepting slower incremental compiles in exchange for correctness. Developers who never touch the protobuf modules stay on Bloop.
Step three fixes references by fixing the build: a warnings-as-errors flag failed one module, which never produced SemanticDB, so references into it were missing. Relaxing fatal warnings locally, and keeping them in CI, restored navigation. Caching resolved dependencies in the developer image cut import time roughly in half, and excluding generated directories from the file watcher stopped the editor stalling on save.
Failure modes
| Symptom | Likely cause | First response |
|---|---|---|
| New dependency not found in editor, sbt compiles | Build not re-imported | Run Import build; accept the prompt next time |
| Find references or rename misses call sites | Module failed to compile, SemanticDB stale | Fix compile errors, then retry |
| Generated sources stale or missing | Bloop does not re-run sbt generators | Switch to sbt BSP or re-export after generation |
| Class file version errors | javaHome older than a library needs | Set javaHome to the CI JDK |
| Metals slow or out of memory | Many Scala versions, huge watcher load | Exclude .bloop, .metals and target; close unused workspaces |
| Build server stuck | Stale Bloop or sbt process | metals.build-restart; enable shutdownBloopOnEditorClose |
For build-tool specifics, see sbt in depth and Mill in depth; for how SemanticDB is produced as a compiler plugin, see Scala compiler plugins.
Trade-offs
Metals trades a little accuracy for a lot of speed. The presentation compiler answers in milliseconds without compiling the world, but it trusts the imported classpath. SemanticDB gives precise cross-file references, but only as fresh as the last successful compile. Bloop gives fast feedback, but maintains a copy of your build model. None of this is hidden: every answer comes from a specific source, and knowing which one tells you whether to re-import, recompile or restart.
What to do next
- Open your project, run
Run Doctor, and confirm each module shows the right Scala version and has SemanticDB enabled. - Set
javaHometo the same JDK your CI uses and commit it in workspace settings. - Add
.metals/,.bloop/,.scala-build/andmetals.sbtto.gitignore; commit.scalafmt.conf. - If your build relies on source generators or custom sbt tasks, try sbt BSP with
metals.bsp-switchand compare. - Always accept the re-import prompt after changing the build, and fix compile errors before renaming.
- If you use a coding agent, run
metals-mcp --workspaceagainst an imported build and check it can resolve a symbol from your own code.