Mill is a modern build system for Scala that rethinks the entire category from first principles, discarding sbt’s complexity in favor of a simple, composable task graph. Where sbt uses a stateful, mutation-heavy plugin ecosystem and a domain-specific language that often surprises even experienced users, Mill treats your build as ordinary Scala code — a recursive, hierarchical task definition with transparent dependencies. The payoff is speed (no unnecessary work, fine-grained incremental compilation), clarity (your build reads like data rather than a black box), and predictability (fewer surprises from implicit defaults or plugin ordering). This piece walks the whole design: why Mill chose a functional task graph, how to write a build.sc that is readable and maintainable, how modules and nested builds work, the performance characteristics that make it fast, how to migrate from sbt without pain, and when Mill is the right choice over the status quo.

Why a new build tool? The sbt problem space

Before understanding Mill, it helps to see what it is reacting to. sbt (Scala Build Tool) has dominated Scala builds for over a decade, but it arrives with baggage. sbt’s model is stateful: the build state is a giant Map of settings, and plugins monkey-patch that map at runtime, often in order-dependent ways that lead to subtle bugs when the wrong plugin loads first. The DSL — a dialect of Scala with special syntax — is powerful but bewildering, mixing eager and lazy evaluation in ways that surprise even experts. Task dependencies are implicit, inferred from what a task reads from the settings map, which makes optimization hard and debugging opaque.

More concretely, sbt is slow. Startup time is measured in seconds. Full rebuilds drag because the system has no way to know what really changed, so it often recompiles code that did not touch the compiled outputs. And the plugin ecosystem, while rich, is inconsistent: plugins vary wildly in quality, performance, and adherence to defaults. A large build can accumulate dozens of plugins, each with its own semantics, and debugging a failed compile or test requires digging through layers of implicit transformations.

Mill steps back and asks: what if the build was just a functional, immutable task graph, defined in ordinary Scala? No global state, no plugin ordering surprises, no implicit defaults — just transparent, composable functions with explicit inputs and outputs. What if every directory with tasks was a module, and nested directories just nested modules? What if incremental compilation was automatic, because you declared exactly which files go into each step?

Advertisement

Core design: the functional task graph

The heart of Mill is Target, a type that represents a single computational step: a function from its input targets to an output. A target has explicit inputs (other targets it depends on), a pure Scala function to compute the output, and a cache key for caching (usually the input hashes). Because targets are values, not mutations, Mill can reason about them: it can topologically sort them, run them in parallel where safe, and skip targets whose inputs have not changed.

def name: T[String] = {
  "myapp"
}

def version: T[String] = {
  "1.0.0"
}

def fullName: T[String] = T {
  s"${name()} v${version()}"
}

Each T { ... } block automatically captures its dependencies: when you call name(), Mill records that fullName depends on name. This is a compile-time abstraction, not runtime magic. The result is a static task graph that can be inspected before any work happens, printed for debugging, and executed with full parallelism.

Contrast this with sbt: plugins implicitly modify the settings map, dependencies are inferred dynamically, and the order of plugin loading can change behavior. Mill’s model is compositional by default. You nest modules, override targets, compose larger tasks from smaller ones — all without side effects or global state.

Modules and hierarchical builds

Mill organizes code via modules. Each module is a directory with a companion source tree, and modules can nest: a app/ directory with an app/mill/ subdirectory automatically creates nested modules. In your build.sc, you define module classes:

object core extends mill.ScalaModule {
  def scalaVersion = "3.3.1"
}

object app extends mill.ScalaModule {
  def scalaVersion = "3.3.1"
  def moduleDeps = Seq(core)
}

The module hierarchy is structural: core and app are values in your build, not just directory names. Each module inherits targets from a base class like ScalaModule, which provides compile, test, jar, run, and other standard tasks. You override a target to customize behavior:

object app extends mill.ScalaModule {
  def scalaVersion = "3.3.1"
  def moduleDeps = Seq(core)
  override def scalacOptions = "-Werror" :: super.scalacOptions()
}

This is ordinary Scala: no magic syntax, no plugins, just inheritance and override. The entire build is discoverable by reading one file.

Build.sc and the DSL

Your entire build lives in build.sc at the root, a plain Scala file that imports Mill and defines modules as top-level values. Unlike sbt, there is no special sbt shell or REPL; you run mill on the command line, and it parses build.sc fresh each time. This means:

  • No stale state in the REPL. Every run sees the current build.sc.
  • Your build is side-effect free at definition time; side effects only happen when you invoke a target.
  • Debugging a broken build is straightforward: just read build.sc.

The millfile API is Scala with a few extensions. You define modules, override targets, compose tasks:

import mill._, scalalib._

object build extends RootModule {
  object lib extends ScalaModule {
    def scalaVersion = "3.3.1"
    def ivyDeps = Agg(
      ivy"com.softwaremill::tapir:1.7.0",
      ivy"org.typelevel::cats:2.10.0"
    )
  }

  object app extends ScalaModule {
    def moduleDeps = Seq(lib)
    def mainClass = Some("myapp.Main")
  }
}

The ivy"..." syntax is a macro that parses Maven coordinates at compile time. The Agg type is an immutable aggregate (like a set). Everything is explicit, nothing is hidden in a plugin.

Compilation, testing, and standard tasks

Every ScalaModule brings a standard suite of tasks: compile (compile the sources), test (run tests), jar (package a JAR), run (execute the main class), publish (push to a Maven repository), and many more. The targets are chained:

mill app.run      # compile and run
mill app.test     # compile and test
mill app.jar      # compile and package
mill show app.jar # show the JAR path

The magic is that Mill knows the dependencies. When you run app.test, it automatically compiles lib (because app depends on it), then compiles app, then compiles and runs the test sources. And when you run it again, it only recompiles what changed, by hashing the source files and comparing to the previous run.

Custom tasks are just Scala functions. Want a task that counts lines of code?

object app extends ScalaModule {
  def lineCount: T[Int] = T {
    val files = os.walk.attrs(millSourcePath / "src", _.ext == "scala")
    files.map(os.read(_).split("\n").length).sum
  }
}

You call it the same way: mill app.lineCount. No plugin API to learn, no registration ceremony — just a Scala function that declares what it reads.

Incremental compilation and caching

Mill’s caching strategy is automatic. Every target is cached by the hash of its inputs. When you re-run a target, Mill checksums the input targets; if the hashes match the previous run, it loads the cached output from disk without re-executing the function. If an input changed, the cache is invalidated and the target runs again.

The real win is in incremental compilation. Mill tracks which source files have changed since the last compile and, in many cases, can recompile just the affected files. This is especially fast for large codebases where a one-line change in a utility module used to trigger a full rebuild. Mill uses the Scala compiler’s own incremental compilation infrastructure, so you get its optimizations automatically.

The downside is that Mill will not rebuild what it thinks is already built, even if a transitive dependency changed in a way that should invalidate the cache. This is rare but possible if, for example, you upgrade a dependency without changing the build.sc. The fix is a clean build:

mill clean app.compile
mill app.compile

For most workflows, caching is transparent and fast enough that full rebuilds feel nearly instant.

Advertisement

Performance characteristics and speed

Mill is fast, but for specific reasons, and it pays to understand the trade-offs:

  • No REPL overhead: Every mill invocation parses build.sc fresh. For small builds this is negligible (tens of milliseconds), but large monorepos with thousands of modules can see a second or two of parse time before any real work starts.
  • Minimal defaults: sbt ships with dozens of built-in settings that apply to every project; Mill applies nothing unless you ask. This cuts bloat and makes unexpected behavior rare.
  • Dependency tracking is precise: Because targets are explicit functions, Mill knows exactly which targets need to run. In sbt, a plugin might recompile sources even if nothing changed, just to be safe. Mill skips it.
  • Parallel execution: Mill runs independent targets in parallel by default, which shines on multi-core machines. A monorepo where multiple modules are independent can compile them simultaneously.

In practice, a typical Scala project compiles 30–50% faster under Mill than sbt, and incremental compiles (the common case) are often 2–3x faster because Mill skips any target that was not touched.

Dependency management and Ivy

Mill uses Ivy for dependency resolution, the same system sbt uses under the hood, so Maven artifact compatibility is complete. You declare dependencies in the familiar syntax:

def ivyDeps = Agg(
  ivy"com.typesafe.akka::akka-actor:2.8.1",
  ivy"io.circe::circe-core:0.14.6"
)

def testIvyDeps = Agg(
  ivy"org.scalatest::scalatest:3.2.17"
)

Mill respects version ranges, exclusions, and Scala version cross-building (the :: syntax). It also handles cross-modules — modules that compile against multiple Scala versions or configurations in a single build:

object lib extends Cross[ScalaModule]("3.3.1", "2.13.12") {
  def crossScalaVersion = crossValue
}

This generates lib[3.3.1] and lib[2.13.12] targets automatically, compiling the same sources with both versions. No sbt ++ switching; just declare the versions and run.

Migration from sbt

Converting an sbt build to Mill is not automatic, but it is not painful either. Most projects can be ported in a day or two. Here is the rough path:

  • Start simple: Create a minimal build.sc that mirrors your build.sbt structure. Copy module names, dependency lists, and settings.
  • Translate settings: Most sbt settings have Mill equivalents. Common ones like scalaVersion, libraryDependencies, and mainClass are direct. For plugin-specific settings, check the Mill plugin documentation (e.g., mill-scalafix, mill-tpolecat).
  • Handle custom tasks: sbt tasks defined in TaskKey become Mill targets. This usually means replacing lazy val compile = Def.task { ... } with def compile: T[...] = T { ... }.
  • Test locally: Run mill app.compile app.test app.jar and confirm the outputs match what sbt produced. If a test fails, trace the input to the failing target.

One gotcha: if your sbt build uses many plugins (especially custom ones), you may need to reimplement some logic in Mill. But because Mill builds are just Scala, this is usually faster than wrestling with a plugin API.

Trade-offs and gotchas

What Mill is good at: New projects, teams that value speed and clarity, monorepos where you want to avoid plugin complexity, and codebases where most modules are independent. Mill shines when your build is a means to an end, not an intricate work of art.

Where Mill falls short: If your project heavily relies on sbt plugins (especially complex ones like sbt-assembly or sbt-protobuf), you may not find Mill equivalents. The Mill plugin ecosystem is smaller and less mature than sbt’s. Also, if you have many developers deeply familiar with sbt, retraining on a new mental model takes time.

Gotchas in practice: The cache is aggressive; if you manually edit generated files, Mill may not rebuild them (run mill clean). The build.sc is re-parsed on each invocation, so large polyglot builds (many languages, conditional logic) can feel slow at startup. And Mill’s error messages, while improving, are sometimes less friendly than sbt’s when a module is missing a dependency.

Mill is a fresh rethinking of the Scala build problem: a functional task graph written in plain Scala, with explicit targets, transparent dependencies, and automatic caching. Where sbt is stateful and mutation-heavy, Mill is composable and declarative, making your build.sc readable as data rather than a black box. Modules are just nested Scala values, cross-compilation is first-class, and incremental compilation is automatic. In practice, builds compile 30–50% faster and are far easier to debug. The trade-off is that the plugin ecosystem is smaller and porting an sbt build with heavy plugin use requires some work. But for new projects and teams that value clarity, Mill is worth the switch.