Scalafix is a refactoring and linting tool for Scala from the Scala Center. It reads your code as a tree instead of as text, applies rules, and either rewrites files or reports diagnostics. That one engine covers three jobs teams usually solve separately: keeping imports tidy and dead code gone, enforcing house rules such as no nulls or no Option.get, and running one-off migrations across a large codebase when a library or language version changes.

The part that trips people up is that scalafix has two kinds of rule with very different requirements. Syntactic rules need only the parser and run in milliseconds. Semantic rules need to know what every name refers to, so they depend on compiler output called SemanticDB, and they are only as correct as that output is fresh. This article explains the model, sets scalafix up in sbt and CI, and writes, tests and ships a custom semantic rule.

Advertisement

The mental model: trees, symbols and patches

Scalafix is built on scalameta, a library that parses Scala 2 and Scala 3 source into trees that keep every token, including whitespace and comments. Because nothing is lost, a rule can replace one node and leave the rest of the file byte-for-byte identical. A regex cannot tell a method call from a string literal or comment; a tree walker can.

A rule does not edit files directly. It returns a Patch, a value describing edits (replace this tree, remove these tokens, add this import) and lint diagnostics. Scalafix merges the patches from all rules, applies them, and writes the result or, in check mode, reports what would change. Because patches are values, rules compose, and a rule that finds nothing returns Patch.empty.

The name get in x.get could be Option#get, Map#get or a method on your own class. Syntax alone cannot tell them apart. The compiler can, and with the SemanticDB compiler plugin enabled it writes a .semanticdb file per source file recording which symbol each name resolves to, inferred types and compiler diagnostics such as unused warnings. Semantic rules read those files.

How scalafix sees your code: syntax always, semantics only after compilationSource files*.scalascalameta parsertrees + tokensscalac + SemanticDBcompile step.semanticdb filessymbols, types, diagsRulesSyntactic / SemanticPatchedits + lintsRewritten sourcesor --check diffLint diagnosticserror / warningcompileSyntacticDocumentSemanticDocumentSyntactic rules need only the parser; semantic rules read SemanticDB, so the code must compile firstand the .semanticdb files must match the current sources, or patches land in the wrong place
Scalafix pipeline. Syntactic rules work from the parse alone; semantic rules also read SemanticDB produced by a prior compile.

Syntactic versus semantic rules

Syntactic ruleSemantic rule
Inputsource text parsed by scalametasource plus matching .semanticdb files
Needs compilationnoyes, with SemanticDB enabled
Knows what a name refers tono, only its spelling and shapeyes, fully qualified symbols and types
Speedfast, can run on uncompilable codebounded by compile time
Built-in examplesDisableSyntax, ProcedureSyntax, RedundantSyntax, NoValInForComprehension, LeakingImplicitClassValRemoveUnused, OrganizeImports (removeUnused), ExplicitResultTypes, NoAutoTupling
Typical usestyle bans, deprecated syntaxdead code, API migrations, type-aware bans

Pick the weakest kind that is correct: spotting a var is syntactic; knowing that a call targets a specific library method is semantic and costs a compile.

Advertisement

Setting it up in sbt

The sbt plugin adds the tasks and wires SemanticDB. Enable SemanticDB for the build, pin its version to the one scalafix expects, and turn on the compiler's unused warnings, because RemoveUnused and OrganizeImports' unused-import removal act on those diagnostics rather than working out usage themselves.

// project/plugins.sbt
addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.9")

// build.sbt
ThisBuild / semanticdbEnabled := true                              // emit SemanticDB
ThisBuild / semanticdbVersion := scalafixSemanticdb.revision       // version scalafix expects
ThisBuild / scalafixDependencies +=
  "org.typelevel" %% "typelevel-scalafix" % "<version>"             // third-party rules

lazy val core = project.settings(
  scalacOptions ++= {
    CrossVersion.partialVersion(scalaVersion.value) match {
      case Some((2, 12)) => Seq("-Ywarn-unused")
      case Some((2, 13)) => Seq("-Wunused")
      case _             => Seq("-Wunused:all")                     // Scala 3.3.4+
    }
  }
)

On Scala 3, unused-import removal needs 3.3.4 or later because earlier compilers did not export unused diagnostics to SemanticDB, and removing unused parameters needs 3.7.0 or later. If your build uses -Xfatal-warnings, the unused warnings will fail compilation before scalafix can fix them; either demote them with -Wconf:cat=unused:info on versions that support it or drop fatal warnings for the scalafix run. Mill runs scalafix through a plugin with the same SemanticDB requirement; see the Mill build tool and, for how sbt settings and scopes compose, sbt in depth.

If you only want to try scalafix without committing build changes, scalafixEnable turns SemanticDB on for the current sbt shell session.

The built-in rules worth enabling

  • OrganizeImports: sorts, groups and de-duplicates imports, and with removeUnused = true removes unused ones. Defaults group by "*" then java/javax/scala, explode grouped imports into one per line and sort by ASCII. It is the rule with the biggest effect on review noise.
  • RemoveUnused: removes unused imports, private members and locals, and replaces unused pattern variables and parameters with _. All five switches default to on. If you run OrganizeImports, turn off its imports switch so the two do not both edit import lines.
  • DisableSyntax: a configurable ban list for constructs such as var, null, return, throw, XML literals and asInstanceOf. It lints rather than rewrites.
  • ExplicitResultTypes: inserts inferred result types on public members, which stabilises public APIs and speeds incremental compilation. Rules for which members it touches are configurable; check its Scala 3 support against the version you run.
  • ProcedureSyntax, NoValInForComprehension, RedundantSyntax, NoAutoTupling, LeakingImplicitClassVal: small mechanical clean-ups, mostly useful when modernising Scala 2 code before a move to Scala 3. For the language side of that move, see Scala 3 in depth.

Configuration

Rules and their settings live in .scalafix.conf, a HOCON file at the repository root. Rules listed there run when you invoke scalafix with no arguments; naming a rule on the command line runs only that one. Keep the file in version control so every developer and CI run the same rule set.

# .scalafix.conf at the repository root (HOCON)
rules = [
  OrganizeImports
  RemoveUnused
  DisableSyntax
  ProcedureSyntax
  RedundantSyntax
  NoOptionGet            # the custom rule written later in this article
]

OrganizeImports {
  groups = ["re:javax?\\.", "scala.", "*", "com.example."]
  removeUnused = true                 # needs Scala 2 or Scala 3.3.4+
}

RemoveUnused {
  imports = false                     # OrganizeImports already handles imports
}

DisableSyntax {
  noVars = true
  noNulls = true
  noReturns = true
  noXml = true
}

Order the import groups to match what your formatter and IDE expect, or the three will fight. Scalafmt formats but does not reorder imports semantically, and scalafix does not format, so run scalafix first and scalafmt second: rewrites can leave awkward spacing that the formatter then normalises.

Running it: locally and in CI

# Developer loop: rewrite in place, main + test sources
sbt "scalafixAll"

# One rule, one module, main sources only
sbt "core/scalafix OrganizeImports"

# CI: fail if any rule would change a file or reports a lint error
sbt "scalafixAll --check"

# Run a rule published as a library without adding it to the build
sbt "scalafixAll dependency:SomeRule@com.example::example-rules:1.2.3"

# Standalone CLI (Coursier)
cs install scalafix
scalafix --rules ProcedureSyntax src/main/scala

scalafix covers one configuration (main sources by default); scalafixAll covers all configurations, including tests. --check is the CI mode: it rewrites nothing and exits non-zero if any file would change or any lint error is reported, printing a diff for the rewrites. Put it in the same CI job as compilation so SemanticDB is already fresh.

Add rules to a mature codebase in two steps: run the rewrite once and commit the mechanical diff on its own (listed in .git-blame-ignore-revs), then enable --check so the code cannot drift back. For lint rules with many existing findings, suppress or fix them module by module first, or the first CI run is a wall of red nobody owns.

Third-party rules

Rules are ordinary JVM libraries. scalafixDependencies adds them to the scalafix classpath without touching your application's dependencies, and the rule names become available in .scalafix.conf. Library authors publish migration rules alongside breaking releases, and ecosystem collections add lints such as type-aware bans. The dependency: prefix on the command line runs a published rule once, which suits one-off migrations you never want in the permanent rule set.

Treat third-party rules like code with write access to your repository: pin versions and run them on a branch first.

Writing a custom rule: ban Option.get

A worked example makes the Patch API concrete. The goal is to fail CI whenever code calls Option.get, which throws on None, without flagging Map.get, which returns an Option and is fine. The text .get is identical in both, so this must be a semantic rule that checks the resolved symbol.

// rules/src/main/scala/fix/NoOptionGet.scala
package fix

import scalafix.v1._
import scala.meta._

final case class OptionGetUsed(t: Term) extends Diagnostic {
  override def position = t.pos
  override def message =
    "Option.get throws on None; use getOrElse, fold or a pattern match"
}

class NoOptionGet extends SemanticRule("NoOptionGet") {
  // SemanticDB symbol for scala.Option#get, not the text ".get"
  private val optionGet =
    SymbolMatcher.normalized("scala/Option#get().", "scala/Some#get().")

  override def fix(implicit doc: SemanticDocument): Patch =
    doc.tree.collect {
      case sel @ Term.Select(_, name @ Term.Name("get")) if optionGet.matches(name.symbol) =>
        Patch.lint(OptionGetUsed(sel))
    }.asPatch
}

// rules/src/main/resources/META-INF/services/scalafix.v1.Rule
//   fix.NoOptionGet

The rule walks the tree, finds every selection of a name get, and asks SemanticDB whether that name resolves to scala/Option#get()., the SemanticDB spelling of the method. Matches produce lint diagnostics, which scalafix reports as errors by default. SymbolMatcher.normalized matches the symbol regardless of overload, but it does not match subtypes: on Scala 2.13 Some overrides get, so add scala/Some#get(). to the matcher too. The service file under META-INF/services lets scalafix discover the rule by name.

Keep such a rule in its own sbt subproject that depends on scalafix-core, and point the build at it: sbt-scalafix supports local rules through a rules project added with dependsOn(rules % ScalafixConfig). For a company-wide rule, publish the subproject as a library and consume it through scalafixDependencies in every repository.

Rewrites use the same shape. A syntactic rule can replace a deprecated helper with its successor:

// A syntactic rewrite: replace a deprecated helper call with its successor.
class RenameLegacyClient extends SyntacticRule("RenameLegacyClient") {
  override def fix(implicit doc: SyntacticDocument): Patch =
    doc.tree.collect {
      case t @ Term.Apply(Term.Name("legacyClient"), args) =>
        Patch.replaceTree(t, s"HttpClient.default(${args.mkString(", ")})")
    }.asPatch
}

Other patch builders add and remove imports (Patch.addGlobalImport, Patch.removeImportee) and tokens, and patches combine with +. If the rewrite must depend on types, make it a SemanticRule.

Testing rules with scalafix-testkit

Rules deserve tests because a buggy rewrite runs across every file in the codebase. The testkit convention uses three source sets: input files carry a comment header naming the rule and, for lints, // assert: markers on lines that must produce a diagnostic; output files hold the expected rewritten source; and a tests project runs each input through the rule and diffs the result.

// input/src/main/scala/fix/NoOptionGetTest.scala
/*
rule = NoOptionGet
*/
package fix

object NoOptionGetTest {
  val port: Option[Int] = sys.env.get("PORT").map(_.toInt)
  val p = port.get                             // assert: NoOptionGet
  val m = Map("a" -> 1).get("a")               // Map#get returns Option: no finding
}

The Map#get line is the important one: a test that only proves the rule fires does not prove it stays quiet on the look-alike. Add cases for method references, infix calls and code inside string interpolation, which is where tree-based rules most often surprise their authors. For general testing patterns in Scala, see testing Scala code.

Suppressing findings

// The pool guarantees Some here
val conn = pool.borrow().get   // scalafix:ok NoOptionGet

// scalafix:off DisableSyntax.noNulls
val jni: Pointer = null        // interop with a C library
// scalafix:on DisableSyntax.noNulls

// scalafix:ok suppresses one line for a named rule; scalafix:off and scalafix:on bracket a region. Always name the rule so the suppression does not silently cover rules added later, and count suppressions over time: a rule with hundreds of them is the wrong rule for your codebase.

Failure modes

FailureSymptomFix
Stale SemanticDBsemantic rule edits the wrong span or reports 'stale semanticdb' for a filerecompile before running; never edit files between compile and scalafix
Code does not compilesemantic rules skip files or failrun syntactic rules first, fix compile errors, then semantic rules
-Xfatal-warnings onunused warnings stop the compile, so RemoveUnused never runsdemote unused warnings with -Wconf or relax fatal warnings for the run
SemanticDB version mismatchrules see no symbols or crashset semanticdbVersion := scalafixSemanticdb.revision
Rules and formatter disagreeevery run churns import order or spacingalign OrganizeImports groups with scalafmt and IDE settings; run scalafix before scalafmt
Two rules edit the same linesconflicting patch errors or odd outputdisable overlapping switches (RemoveUnused.imports with OrganizeImports)
Rule assumes Scala 2 treesno findings on Scala 3 code, or parse errorscheck the rule's Scala 3 support; test with cross-built inputs

Trade-offs

Scalafix adds a SemanticDB compile, a configuration file and occasional rule conflicts to the build. In return you get tree-accurate rewrites and lints a formatter cannot express. Compiler-plugin linters such as WartRemover run inside compilation with no separate step but only report; scalafix can also rewrite and run one-off migrations. A common split: compiler warnings for the basics, scalafix for imports, bans and migrations, scalafmt for layout. Implicit and given resolution is exactly the kind of information SemanticDB exposes to rules; implicit resolution in depth explains what the compiler records.

What to do next

  1. Add sbt-scalafix, enable SemanticDB with scalafixSemanticdb.revision and turn on the unused warnings for your Scala version.
  2. Create .scalafix.conf with OrganizeImports and RemoveUnused (imports off), run scalafixAll, and commit the result as one mechanical commit listed in .git-blame-ignore-revs.
  3. Add scalafixAll --check to CI in the job that already compiles.
  4. Enable DisableSyntax with the two or three bans your team actually agrees on; suppress legacy findings by module, then fix them.
  5. Write one custom semantic rule for a bug your team has shipped more than once, with testkit inputs that include a look-alike that must not fire.
  6. Before your next library upgrade, check whether the library ships a scalafix migration rule and run it with dependency: on a branch.
Key takeaway: Scalafix rewrites and lints Scala as trees, so its edits are precise where text tools are not. Syntactic rules run on the parse alone; semantic rules need fresh SemanticDB from a successful compile and give you symbol-accurate matching. Configure it in one checked-in file, run scalafix before scalafmt, enforce it with --check in CI, and use custom semantic rules with testkit tests to stop recurring bugs and to automate migrations.