Why Use Mill?
Mill provides 3 major advantages over other build tools. The comparison pages
for the respective build tool go into more detail (for Maven,
Gradle, and sbt
), but at a high level
these advantages are:
-
Performance: Mill offers a 3-6x speedup means less time waiting for your build tool, meaning less time waiting for your build and more time doing useful work
-
Extensibility: Mill lets you write code or use any published JVM library in your build, customizing it to your needs without being limited by third-party plugins
-
IDE Support: Mill has better IDE support in IntelliJ and VSCode and richer visualization tools than other tools, to help understand your build and what it is doing
At a first glance, Mill looks like any other build tool. You have build files, you configure dependencies, you can compile, run, or test your project:
// build.mill
package build
import mill._, javalib._
object foo extends JavaModule {
def ivyDeps = Seq(
ivy"net.sourceforge.argparse4j:argparse4j:0.9.0",
ivy"org.thymeleaf:thymeleaf:3.1.1.RELEASE"
)
object test extends JavaTests with TestModule.Junit4
}
> /mill foo.compile
compiling 1 Java source...
> /mill foo.run --text hello
<h1>hello</h1>
> ./mill foo.test
Test foo.FooTest.testEscaping finished, ...
Test foo.FooTest.testSimple finished, ...
0 failed, 0 ignored, 2 total, ...
However, beyond the basics, Mill does provide a lot that other JVM build tools do not. We will look at each topic in turn:
Performance
Maven
Overall across our benchmarks, Mill is 4-6x faster than Maven for clean compiles, both parallel and sequential, and for many modules or for a single module:
Benchmark |
Maven |
Mill |
Speedup |
98.80s |
23.41s |
4.2x |
|
48.92s |
9.29s |
5.3x |
|
4.89s |
0.88s |
5.6x |
|
6.82s |
0.18s |
37.9x |
|
5.25s |
0.12s |
43.8x |
First, let’s look at Parallel Clean Compile All.
This benchmark involves running clean
to delete all generated files and re-compiling
everything in parallel. Mill sees a significant ~6x speedup over Maven for this benchmark.
You can click on the link above to see a more detailed discussion of how this benchmark was
run.
The second benchmark worth noting is Incremental Compile Single Module.
This benchmark involves making a single edit to a single already-compiled file in common
-
adding a single newline to the end of the file - and re-compiling common
and common.test
.
Mill sees a huge ~12x speedup for this benchmark, because Mill’s incremental compiler
(Zinc) is able to detect that only one file in one module
has changed, and that the change is small enough
to not require other files to re-compile. In contrast, Maven re-compiles all files in both
modules, even though only one file was touched and the change was trivial.
Gradle
The comparison with Gradle is less stark, but still significant. Mill is 3-4x faster than Gradle across the various workflows:
Benchmark |
Gradle |
Mill |
Speedup |
17.6s |
5.86s |
3.0x |
|
12.3s |
3.75s |
3.3x |
|
4.41s |
1.30s |
3.4x |
|
1.37s |
0.20s |
6.9x |
|
0.94s |
0.11s |
8.5x |
Mill’s various "clean compile" workflows 3-4x faster than Gradle’s, while it’s incremental and no-op compile workflows are 7-9x faster. Both Gradle and Mill appear to do a good job limiting the compilation to only the changed file, but Mill has less fixed overhead than Gradle does, finishing in about ~0.2s rather than ~1.5 seconds.
In general, these benchmarks don’t show Mill doing anything that Maven or Gradle do not: these are equivalent builds for the same projects (Netty and Mockito respectively), compiling the same number of files using the same Java compiler, in the same module structure and passing the same suite of tests. Rather, what we are seeing is Mill simply having less build-tool overhead than Maven or Gradle, so the performance of the underlying JVM and Java compiler (which is actually pretty fast!) can really shine through.
Extensibility
Mill allows you to directly write code to configure your build, and even download libraries from Maven Central.
Most build tools need plugins to do anything: if you want to Foo you need a Foo plugin, if you want to Bar you need a Bar plugin, for any possible Foo or Bar. These could be simple tasks - zipping up files, pre-rendering web templates, preparing static assets for deployment - but even a tasks that would be trivial to implement in a few lines of code requires you to Google for third-party plugins, dig through their Github to see which one is best maintained, and hope for the best when you include it in your build. And while you could write plugins yourself, doing so is usually non-trivial.
Mill is different. Although it does have plugins for more advanced integrations, for most simple things you can directly write code to achieve what you want, using the bundled filesystem, subprocess, and dependency-management libraries. And even if you need third-party libraries from Maven Central to do Foo, you can directly import the "Foo" library and use it directly, without having to find a "Foo build plugin" wrapper.
Simple Custom Tasks
The following Mill build is a minimal Java module foo
. It contains no custom configuration, and
so inherits all the defaults from mill.javalib.JavaModule
: default source folder layout, default
assembly configuration, default compiler flags, and so on.
package build
import mill._, javalib._
object foo extends JavaModule {
}
> mill compile
Compiling 1 Java source...
If you want to add a custom task, this is as simple as defining a method e.g.
def lineCount = Task { … }
. The body of Task
performs the action we want, and
can depend on other tasks such as allSourceFiles()
below:
package build
import mill._, javalib._
object foo extends JavaModule {
/** Total number of lines in module source files */
def lineCount = Task {
allSourceFiles().map(f => os.read.lines(f.path).size).sum
}
}
Once we define a new task, we can immediately begin using it in our build.
lineCount
is not used by any existing JavaModule
tasks, but we can still
show its value via the Mill command line to force it to evaluate:
> mill show foo.lineCount
17
Note that as lineCount
is a Task
, we get automatic caching, invalidation, and
parallelization: these are things that every Task
gets for free, without the task
author to do anything. And although we wrote the lineCount
logic in the main
build.mill
file for this example, if it grows complex enough to get messy it is
easy to move it to your own custom plugins
Overriding Tasks
To wire up lineCount
into our main JavaModule
compile
/test
/run
tasks,
one way is to take the line count value and write it to a file in def resources
.
This file can then be read at runtime as a JVM resource. We do that below
by overriding def resources
and making it depend on lineCount
, in addition
to its existing value super.resources()
:
package build
import mill._, javalib._
object foo extends JavaModule {
/** Total number of lines in module source files */
def lineCount = Task {
allSourceFiles().map(f => os.read.lines(f.path).size).sum
}
/** Generate resources using lineCount of sources */
override def resources = Task {
os.write(Task.dest / "line-count.txt", "" + lineCount())
super.resources() ++ Seq(PathRef(Task.dest))
}
}
Because our def resources
overrides the existing resources
method inherited from JavaModule
,
the downstream tasks automatically now use the new override instead, as that is how overrides
work. That means if you call mill foo.run
, it will automatically pick up the new resources
including the generated line-count.txt
file and make it available to
the application code to use e.g. to print it out at runtime:
> mill foo.run
Line Count: 18
Next, we’ll look at a more realistic example, which includes usage of third-party libraries in the build.
Using Third-Party JVM Libraries in Tasks
Earlier on we discussed possibly pre-rendering HTML pages in the build so they can be served at runtime. The use case for this are obvious: if a page never changes, rendering it on every request is wasteful, and even rendering it once and then caching it can impact your application startup time. Thus, you may want to move some HTML rendering to build-time, but with traditional build tools such a move is sufficiently inconvenient and complicated that people do not do it.
With Mill, pre-rendering HTML at build time is really easy, even if you need a third-party
library. Mill does not ship with a bundled HTML templating engine, but you can use the
import $ivy
syntax to include one such as Thymeleaf, which would immediately make the
Thymeleaf classes available for you to import and use in your build as below:
package build
import mill._, javalib._
import $ivy.`org.thymeleaf:thymeleaf:3.1.1.RELEASE`
import org.thymeleaf.TemplateEngine
import org.thymeleaf.context.Context
object foo extends JavaModule {
/** Total number of lines in module source files */
def lineCount = Task {
allSourceFiles().map(f => os.read.lines(f.path).size).sum
}
def htmlSnippet = Task {
val context = new Context()
context.setVariable("heading", "Line Count is: " + lineCount())
new TemplateEngine().process(
"<h1 th:text=\"${heading}\"></h1>",
context
)
}
def resources = Task.Sources{
os.write(Task.dest / "snippet.txt", htmlSnippet())
super.resources() ++ Seq(PathRef(Task.dest))
}
}
Once we have run import $ivy
, we can import TemplateEngine
, Context
, and replace our
def lineCount
with a def htmlSnippet
task that uses Thymeleaf to render HTML. Again,
we get full IDE support for working with the Thymeleaf Java API, the new htmlSnippet
task
is inspectable from the Mill command line via show
, and we wire it up into
def resources
so it can be inspected and used at runtime by the application
(in this case just printed out):
> mill show foo.htmlSnippet
"<h1>Line count is: 17</h1>"
> mill foo.compile
compiling 1 Java source...
...
> mill foo.run
generated snippet.txt resource: <h1>Line count is: 17</h1>
Rendering HTML using the Thymeleaf templating engine is not rocket science, but what is interesting here is what we did not need to do:
-
We did not need to find a Thymeleaf-Mill plugin in order to include Thymeleaf in our build
-
We did not need to learn a special API or framework for authoring build plugins ourselves to write a plugin to include Thymeleaf in our build
-
We did not need to add fragile shell scripts to augment our build logic and implement the functionality we need.
Instead, we could simply import Thymeleaf as a Java library directly from Maven Central and use it just like we would use it in any Java application, with IDE support, typechecking, and automatic parallelism and caching.
Most real projects require some kind of ad-hoc build tasks: you may be pre-processing static assets for web deployment, embedding build metadata for runtime debugging, or pre-rendering HTML pages to optimize performance at runtime. With most build tools, you often needed to pull in some poorly-maintained plugin off of Github, write your own using a complicated plugin framework, or even wrap your build tool in ad-hoc shell scripts. With most other build tools, caching and parallelism are things that the build author needs to use manually, meaning nobody gets it right and your build performance is never as good as it could be.
In contrast, Mill makes it easy it is to write concise type-checked code to perform ad-hoc tasks to do whatever you need to do. You get full IDE support, automatic caching and parallelism, and access to the huge JVM library ecosystem on Maven Central. Rather than grabbing unmaintained plugins off of Github or augmenting your build with fragile shell scripts, Mill allows your own custom logic to be implemented in a way that is flexible, performant, and safe, such that anyone can configure their build correctly and achieve maximum performance even without being a build tool expert.
IDE Support
One area that Mill does better than Gradle is providing a seamless IDE experience. For example,
consider the snippet below where we are using Gradle to configure the javac compiler options.
Due to .gradle
files being untyped Groovy, the autocomplete and code-assist experience working
with these files is hit-or-miss. In the example below, we can see that IntelliJ is able to identify
that compileArgs
exists and has the type List<String>
:

But if you try to jump to definition or find out anything else about it you hit a wall:

Often working with build configurations feels like hitting dead ends: if you don’t have
options.compilerArgs
memorized in your head, there is literally nothing you can do in your editor to
make progress to figure out what it is or what it is used for. That leaves you googling
for answers, which can be a frustrating experience that distracts you from the task at hand.
The fundamental problem with tools like Gradle is that the code you write does not
actually perform the build: rather, you are just setting up some data structure that
is used to configure the real build engine that runs later. Thus when you explore
the Gradle build in an IDE, the IDE can only explore the configuration logic (the
getCompilerArgs
method above) and is unable to explore the actual build logic (how
getCompilerArgs
actually gets used in Gradle)
In comparison, not only are Mill’s .mill
files statically typed, allowing IDEs like IntelliJ
to pull up the documentation for def javacOptions
:

The way Mill builds are structured also helps the IDE: Mill
code actually performs your build, rather than configuring some opaque build engine.
While that sounds academic, one concrete consequence is that IntelliJ is able to take
your def javacOptions
override and
find the original definitions that were overridden, and show you where they are defined:

You can jump to any of the overridden `def`s quickly and precisely:

Furthermore, because task dependencies in Mill are just normal method calls, IntelliJ is
able to find usages, showing you where the task is used. Below, we can see the method
call in the def compile
task, which uses javacOptions()
along with a number of other tasks:

From there, if you are curious about any of the other tasks used alongside javacOptions
, it’s
easy for you to pull up their documentation, jump to their
definition, or find their usages. For example we can pull up the docs of
compileClasspath()
below, jump to its implementation, and continue
interactively exploring your build logic from there:

Unlike most other build tools, Mill builds can be explored interactively in your IDE. If you do not know what something does, it’s documentation, definition, or usages is always one click away in IntelliJ or VSCode. This isn’t a new experience for Java developers, as it is what you would be used to day-to-day in your application code! But Mill brings that same polished experience to your build system - traditionally something that has been opaque and hard to understand - and does so in a way that no other build tool does.
Conclusion
To wrap up, Mill does all the same things that other build tools like Maven or Gradle do, but aims to do them better: faster, easier to use, and easier to extend.
Build systems have traditionally been mysterious black boxes that only experts could work with: slow for unknown reasons, with cargo-culted configuration and usage commands, and challenging for normal application developers to contribute improvements to. Mill flips this on its head, democratizing your build system such that even non-experts are able to contribute, and can do so safely and easily such that your build workflows achieve their maximum possible performance.
The rest of this doc-site contains more Mill build tool comparisons
(with Maven, Gradle,
sbt
), with getting started instructions
for using Mill with Java, with Scala,
or with Kotlin, and detailed documentation for how Mill
works. Please try it out and let us know in the
discussions forum how it goes!