Build Automation Fundamentals

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Ant 1.10.18, Apache Maven 3.10.0 (with 3.9.16 also in use, and Maven 4.0.0-rc-7 named as a release candidate) and Gradle 9.8.1 (8.14 noted where it differs). Every example was run on JDK 26 on 2026-10-16 with the Ant, Maven and Gradle releases named in the text. The small example project and its three build files were written for this page: the tidepool model library, a single module of the larger scenario used across this section, holds one record (Height) and one JUnit Jupiter test class, and is built with Ant, Maven and Gradle from three copies of the same source tree. Paths in the console output were shortened to /work/tidepool-model.

The page answers two questions that come before any tool syntax: why a build should be a program instead of a checklist, and which ideas Ant, Maven and Gradle share. The dependency half of the story is on Dependency Management Fundamentals; the side-by-side verdict is on Comparing Ant, Maven & Gradle. For the tools themselves start at Apache Ant, Apache Maven or Gradle.

Why automate the build

A build turns source files into something that can be run and shipped: compiled classes, a tested and packaged JAR, documentation, a distribution. Doing that by hand, with an IDE button or a list of javac and jar commands in a wiki, fails in predictable ways: steps are forgotten or reordered, the result depends on whose machine produced it, and nobody can say which sources and which library versions went into the file that reached production. A build tool replaces the list with a description that a machine executes, and the description is then code that lives in version control beside the sources.

The benefits are the same for all three tools:

  • Repeatable. The same inputs run through the same steps produce the same outputs, on a laptop and on a server. This is what lets a failed build be investigated and a good one be trusted.

  • Portable. The build runs the same on any developer machine and on the integration server, without an IDE. Ant does it by calling Java classes (tasks) instead of shell commands, as its manual explains; Maven and Gradle go further and fetch the plugins and libraries the build needs.

  • On demand, triggered or scheduled. A developer runs a build on demand from a terminal. A continuous integration server runs it when a push or a pull request triggers it, and runs it on a schedule for nightly or release builds. All three styles use the same command, which is why a build must not need a person at the keyboard (see CI & IDE Integration).

  • The build as code. Because the build is a file, it can be reviewed, diffed, tested, branched and rolled back along with the application. A change to the way the JAR is made is a commit with an author and a reason.

Requirements follow from those goals, and they are the checklist against which Ant, Maven and Gradle can be compared: run the steps in dependency order, without repeating work that is already done; fail loudly and stop at the first failure; resolve libraries from repositories instead of copies in the source tree; run unchanged on any machine and in CI; be extensible with new kinds of step; and produce output that is identical when the input is identical. The next sections take each requirement in turn.

The anatomy of a build tool

Every JVM build tool consists of the same parts, under different names:

Part Ant 1.10.18 Maven 3.10.0 Gradle 9.8.1

Unit of work

a task (javac, jar, junitlauncher), a Java class behind an XML element

a goal of a plugin (compiler:compile, surefire:test)

a task (compileJava, test), an object with inputs, outputs and an action

Grouping and ordering of units

a target lists the tasks it runs and the targets it depends on

a phase of a lifecycle (compile, test, package); goals are bound to phases

tasks declare dependsOn or, more often, depend on each other through the files they produce

Description of the project

the build.xml file, a program in XML

the POM (pom.xml), a declarative model

build.gradle.kts or build.gradle, a program in Kotlin or Groovy, and settings.gradle(.kts)

Inputs and outputs

files named by properties, filesets and paths; the build file decides

fixed by convention: src/main/java in, target out

declared on each task; Gradle compares them between runs

Dependency management

none in the core; Apache Ivy or the Maven Resolver Ant Tasks add it

built in, coordinates in the POM, scopes

built in, coordinates in the build script or a version catalog, configurations

Extension mechanism

Java tasks, macros, Antlibs

plugins and extensions written in Java

plugins and build logic written in Kotlin, Groovy or Java

The rest of this page follows the table from the second row down. Dependency management is a topic of its own on Dependency Management Fundamentals.

The build graph

All three tools treat a build as a graph of steps in which an edge means "this step needs that one to have run first". The tool turns the request on the command line into the set of steps to run, orders them so that dependencies come first, and runs each step. The vocabulary and the shape of the graph differ, and the differences matter in daily use.

flowchart LR subgraph ANT["Ant: a target graph written by hand"] direction LR aC["compile"] --> aCT["compile-tests"] --> aT["test"] --> aP["package"] aJ["jar.check"] --> aP end subgraph MVN["Maven: one fixed lifecycle, goals bound to phases"] direction LR mR["process-resources: resources:resources"] --> mC["compile: compiler:compile"] --> mTR["process-test-resources: resources:testResources"] --> mTC["test-compile: compiler:testCompile"] --> mT["test: surefire:test"] --> mP["package: jar:jar"] end subgraph GRD["Gradle: a task graph derived from inputs and outputs"] direction LR gCJ["compileJava"] --> gCl["classes"] gPR["processResources"] --> gCl gCl --> gJ["jar"] --> gA["assemble"] --> gB["build"] gCl --> gCTJ["compileTestJava"] --> gTC["testClasses"] gPTR["processTestResources"] --> gTC gTC --> gTe["test"] --> gCh["check"] --> gB end

The three graphs describe "compile, test, package" for the tidepool-model module. Ant’s graph is the one the author wrote, target by target. Maven’s is a straight line of phases that the jar packaging fixes in advance, with the plugin goals that do the work bound to the phases. Gradle’s is a graph that the java-library plugin assembles from tasks and their declared inputs and outputs, and in which independent branches (processResources and compileJava) are free to run in any order or at the same time.

Ant: targets and dependencies

An Ant build file is a project of target elements. A target names the targets it depends on and holds the tasks to run. Asking Ant for one target runs its dependencies first, depth first, in the order they are listed. This is the full build file of the example; it declares every directory itself, as Ant has no default layout:

<project name="tidepool-model" default="package" basedir=".">
  <property name="src.dir"     location="src/main/java"/>
  <property name="test.dir"    location="src/test/java"/>
  <property name="build.dir"   location="build"/>
  <property name="classes.dir" location="${build.dir}/classes"/>
  <property name="tests.dir"   location="${build.dir}/test-classes"/>
  <property name="jar.file"    location="${build.dir}/tidepool-model-1.0.0.jar"/>

  <path id="test.classpath">
    <pathelement location="${classes.dir}"/>
    <pathelement location="${tests.dir}"/>
    <fileset dir="lib" includes="*.jar"/>
  </path>

  <target name="clean" description="delete build output">
    <delete dir="${build.dir}"/>
  </target>

  <target name="compile" description="compile main sources">
    <mkdir dir="${classes.dir}"/>
    <javac srcdir="${src.dir}" destdir="${classes.dir}" release="21" includeantruntime="false"/>
  </target>

  <target name="compile-tests" depends="compile" description="compile test sources">
    <mkdir dir="${tests.dir}"/>
    <javac srcdir="${test.dir}" destdir="${tests.dir}" release="21" includeantruntime="false">
      <classpath refid="test.classpath"/>
    </javac>
  </target>

  <target name="test" depends="compile-tests" description="run the unit tests">
    <java classname="org.junit.platform.console.ConsoleLauncher" fork="true" failonerror="true">
      <arg line="execute --scan-classpath ${tests.dir} --disable-banner --disable-ansi-colors --details=summary"/>
      <classpath refid="test.classpath"/>
    </java>
  </target>

  <target name="jar.check">
    <uptodate property="jar.uptodate" targetfile="${jar.file}">
      <srcfiles dir="${classes.dir}" includes="**/*.class"/>
    </uptodate>
  </target>

  <target name="package" depends="test,jar.check" unless="jar.uptodate" description="build the JAR">
    <jar destfile="${jar.file}" basedir="${classes.dir}"/>
  </target>
</project>

Ant can list the targets that carry a description, and the first run shows the order in which the dependency chain executed. The first run below started from a deleted build directory, and the test summary lines of the JUnit console launcher (a plain java call to the JUnit Platform Console Launcher, which is why the lines are prefixed [java]) were reduced to the line that opens them:

$ ant -p
Buildfile: /work/tidepool-model/build.xml

Main targets:

 clean          delete build output
 compile        compile main sources
 compile-tests  compile test sources
 package        build the JAR
 test           run the unit tests
Default target: package

$ ant package
Buildfile: /work/tidepool-model/build.xml

compile:
    [mkdir] Created dir: /work/tidepool-model/build/classes
    [javac] Compiling 1 source file to /work/tidepool-model/build/classes

compile-tests:
    [mkdir] Created dir: /work/tidepool-model/build/test-classes
    [javac] Compiling 1 source file to /work/tidepool-model/build/test-classes

test:
     [java]
     [java] Test run finished after 36 ms
     [java]

jar.check:

package:
      [jar] Building jar: /work/tidepool-model/build/tidepool-model-1.0.0.jar

BUILD SUCCESSFUL
Total time: 0 seconds

The chain ran compile, compile-tests, test, jar.check and package because package depends on test and jar.check and test depends on compile-tests, which depends on compile. Ant has no dry-run option: reading the depends attributes is the way to predict the order, and ant -v or ant -d only add detail to a real run.

A target is executed at most once in the chain that leads to one requested target, even when several targets depend on it. That is not the same as once per invocation. When two targets are named on the command line, Ant resolves each of them as a separate chain, so a shared dependency can run twice. This build file has a diamond, all depends on test and docs, and both depend on compile:

<project name="diamond" default="all">
  <target name="compile"><echo>compiling</echo></target>
  <target name="test"    depends="compile"><echo>testing</echo></target>
  <target name="docs"    depends="compile"><echo>documenting</echo></target>
  <target name="all"     depends="test,docs"><echo>done</echo></target>
</project>
$ ant -f diamond.xml test docs
Buildfile: /work/tidepool-model/diamond.xml

compile:
     [echo] compiling

test:
     [echo] testing

compile:
     [echo] compiling

docs:
     [echo] documenting

BUILD SUCCESSFUL
Total time: 0 seconds

compile ran twice because test and docs were requested as two separate targets. ant -f diamond.xml all runs it once. A build whose targets are expensive therefore gives people one entry point (all, package, release) instead of a list of targets to combine by hand. The rules of target execution (depends ordering, if and unless, extension points) are the subject of Targets & Extension Points.

Maven: phases and goals

Maven does not ask the author to write the graph. It has three built-in lifecycles, default, clean and site, each a fixed list of phases, and a packaging type (jar by default) binds plugin goals to some of those phases. Running a phase runs every phase before it in the lifecycle, and each phase runs the goals bound to it. Phases with nothing bound do nothing. The default lifecycle includes validate, compile, test, package, verify, install and deploy (and the process-*, pre-* and post-* phases between them). This is the example module built from a clean tree:

$ mvn -B clean package
[INFO] --- clean:3.5.0:clean (default-clean) @ tidepool-model ---
[INFO] --- resources:3.5.0:resources (default-resources) @ tidepool-model ---
[INFO] skip non existing resourceDirectory /work/tidepool-model/src/main/resources
[INFO] --- compiler:3.16.0:compile (default-compile) @ tidepool-model ---
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 1 source file with javac [debug release 21] to target/classes
[INFO] --- resources:3.5.0:testResources (default-testResources) @ tidepool-model ---
[INFO] skip non existing resourceDirectory /work/tidepool-model/src/test/resources
[INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ tidepool-model ---
[INFO] Recompiling the module because of changed dependency.
[INFO] Compiling 1 source file with javac [debug release 21] to target/test-classes
[INFO] --- surefire:3.6.0:test (default-test) @ tidepool-model ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running io.tidepool.model.HeightTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.029 s -- in io.tidepool.model.HeightTest
[INFO] Results:
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO] --- jar:3.5.1:jar (default-jar) @ tidepool-model ---
[INFO] Building jar: /work/tidepool-model/target/tidepool-model-1.0.0.jar
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS

The goals that ran are the default bindings of the jar packaging: resources:resources, compiler:compile, resources:testResources, compiler:testCompile, surefire:test and jar:jar, each from the plugin version printed in the log (clean:3.5.0, compiler:3.16.0, surefire:3.6.0 because the POM pins it, jar:3.5.1). The clean lifecycle and the default lifecycle are separate lifecycles, so clean package runs the clean phase of one and then every phase of the other up to package. The excerpt leaves out the lines that report loaded repository prefixes and the blank [INFO] lines.

Maven can show the whole lifecycle for a project without running anything. help:describe with -Dcmd=package prints every phase of the jar lifecycle and the goal bound to it, which is the table behind the straight line in the diagram (the excerpt leaves out the log lines before and after the list):

$ mvn -B help:describe -Dcmd=package
[INFO] 'package' is a phase corresponding to this plugin:
org.apache.maven.plugins:maven-jar-plugin:3.5.1:jar

It is a part of the lifecycle for the POM packaging 'jar'. This lifecycle includes the following phases:
* validate: Not defined
* initialize: Not defined
* generate-sources: Not defined
* process-sources: Not defined
* generate-resources: Not defined
* process-resources: org.apache.maven.plugins:maven-resources-plugin:3.5.0:resources
* compile: org.apache.maven.plugins:maven-compiler-plugin:3.16.0:compile
* process-classes: Not defined
* generate-test-sources: Not defined
* process-test-sources: Not defined
* generate-test-resources: Not defined
* process-test-resources: org.apache.maven.plugins:maven-resources-plugin:3.5.0:testResources
* test-compile: org.apache.maven.plugins:maven-compiler-plugin:3.16.0:testCompile
* process-test-classes: Not defined
* test: org.apache.maven.plugins:maven-surefire-plugin:3.6.0:test
* prepare-package: Not defined
* package: org.apache.maven.plugins:maven-jar-plugin:3.5.1:jar
* pre-integration-test: Not defined
* integration-test: Not defined
* post-integration-test: Not defined
* verify: Not defined
* install: org.apache.maven.plugins:maven-install-plugin:3.2.0:install
* deploy: org.apache.maven.plugins:maven-deploy-plugin:3.2.0:deploy

Maven also repeats work when phases are requested separately, in the same way as Ant. mvn clean test package is read as three requests, and the second lifecycle walk starts again from validate:

$ mvn -B -ntp clean test package
[INFO] --- clean:3.5.0:clean (default-clean) @ tidepool-model ---
[INFO] --- resources:3.5.0:resources (default-resources) @ tidepool-model ---
[INFO] --- compiler:3.16.0:compile (default-compile) @ tidepool-model ---
[INFO] --- resources:3.5.0:testResources (default-testResources) @ tidepool-model ---
[INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ tidepool-model ---
[INFO] --- surefire:3.6.0:test (default-test) @ tidepool-model ---
[INFO] --- resources:3.5.0:resources (default-resources) @ tidepool-model ---
[INFO] --- compiler:3.16.0:compile (default-compile) @ tidepool-model ---
[INFO] --- resources:3.5.0:testResources (default-testResources) @ tidepool-model ---
[INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ tidepool-model ---
[INFO] --- surefire:3.6.0:test (default-test) @ tidepool-model ---
[INFO] --- jar:3.5.1:jar (default-jar) @ tidepool-model ---
[INFO] BUILD SUCCESS

The excerpt keeps only the lines that start a goal and the final status. resources, compiler and surefire ran twice. The advice of the Maven documentation is to name only the last phase wanted (mvn verify, mvn clean deploy), which also runs every earlier phase once.

The same module builds unchanged with Maven 4.0.0-rc-7 (release candidate) on the same JDK, and the plugin goals ran in the same order. Two warnings are new in that release: Maven 4 looks for a .mvn directory to identify the root of the project, and it warns when the plugin versions of the default bindings are not locked in pluginManagement. See Build Lifecycle and Maven 4 Lifecycle.

Gradle: tasks and the task graph

A Gradle build runs in three phases: initialization, which finds the projects, configuration, which evaluates the build scripts and builds the task graph, and execution, which runs the selected tasks. The java-library plugin registers the tasks, and Gradle orders them by the dependencies between them. --dry-run (-m) prints the order without running the actions, which is what Ant is missing:

$ gradle --console=plain --dry-run build
:compileJava SKIPPED
:processResources SKIPPED
:classes SKIPPED
:jar SKIPPED
:assemble SKIPPED
:compileTestJava SKIPPED
:processTestResources SKIPPED
:testClasses SKIPPED
:test SKIPPED
:check SKIPPED
:build SKIPPED
BUILD SUCCESSFUL in 371ms

The order is one valid topological sort of the graph in the diagram: compileJava before classes before jar, and compileTestJava before test. The two branches that do not depend on each other, such as processResources and compileJava, may be run in either order, and Gradle can run them in parallel when the build is configured for that.

Tasks are ordinary objects, so the build file can also declare the same diamond as the Ant example. In Gradle the shared task runs once even when two requested tasks depend on it:

Kotlin DSL
val compile = tasks.register("compile") {
    doLast { println("compiling") }
}
val test = tasks.register("test") {
    dependsOn(compile)
    doLast { println("testing") }
}
val docs = tasks.register("docs") {
    dependsOn(compile)
    doLast { println("documenting") }
}
tasks.register("all") {
    dependsOn(test, docs)
    doLast { println("done") }
}
Groovy DSL
def compile = tasks.register('compile') {
    doLast { println 'compiling' }
}
def test = tasks.register('test') {
    dependsOn compile
    doLast { println 'testing' }
}
def docs = tasks.register('docs') {
    dependsOn compile
    doLast { println 'documenting' }
}
tasks.register('all') {
    dependsOn test, docs
    doLast { println 'done' }
}
$ gradle --console=plain test docs
> Task :compile
compiling
> Task :test
testing
> Task :docs
documenting
BUILD SUCCESSFUL in 242ms
3 actionable tasks: 3 executed

The Kotlin DSL build file and its Groovy twin produced identical output. compile ran once, in a graph computed for the whole request, which is the difference with Ant and Maven that a long command line shows most. The state of the graph is also visible beforehand: the configuration phase finishes before any task action runs, a fact that explains most surprises about when a println in the body of a task appears. See Build Lifecycle and Tasks.

The same build, three vocabularies

Question Ant Maven Gradle

What is a node?

target

phase (with goals bound to it)

task

Who wrote the graph?

the build author, with depends

the lifecycle and the packaging type

the plugins, from task inputs, outputs and dependsOn

Can the order be previewed?

no dry run; read depends

mvn help:describe -Dcmd=package lists the phases and the bound goals; no dry run of the goals themselves

yes, --dry-run

Is a shared dependency run once?

once per requested target

once per lifecycle walk

once per build

Declarative and imperative builds

A build file is declarative when it states what the project is and leaves the how to the tool, and imperative when it states the steps. The three tools sit at different points of that scale, and the position is a design decision with a price:

  • Ant is imperative. The build file above creates directories, calls javac with a source and a destination directory, runs the tests and calls jar. Nothing happens that the file does not say, which makes Ant flexible and makes every project re-invent the same few targets.

  • Maven is declarative. The POM below gives the coordinates, the Java release and one test dependency. How the module is compiled, tested and packaged is decided by the lifecycle and the plugins, and a goal’s behaviour is changed by configuring a plugin, not by writing a step.

  • Gradle is both. Applying the java-library plugin is declarative, and a task action (doLast) or a custom task is imperative. The usual advice is to keep the build script declarative and put steps in task classes and plugins.

The Maven POM of the example:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-model</artifactId>
  <version>1.0.0</version>

  <properties>
    <maven.compiler.release>21</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.build.outputTimestamp>2026-10-01T00:00:00Z</project.build.outputTimestamp>
  </properties>

  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>6.1.3</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.6.0</version>
      </plugin>
    </plugins>
  </build>
</project>

The Gradle build for the same module declares what it is (a Java library, release 21, one test dependency) and says nothing about directories or steps. The plugin supplies the tasks of the graph above:

Kotlin DSL
plugins {
    `java-library`
}

group = "io.tidepool"
version = "1.0.0"

tasks.withType<JavaCompile>().configureEach {
    options.release = 21
}

repositories {
    mavenCentral()
}

dependencies {
    testImplementation(platform("org.junit:junit-bom:6.1.3"))
    testImplementation("org.junit.jupiter:junit-jupiter")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.test {
    useJUnitPlatform()
}
Groovy DSL
plugins {
    id 'java-library'
}

group = 'io.tidepool'
version = '1.0.0'

tasks.withType(JavaCompile).configureEach {
    options.release = 21
}

repositories {
    mavenCentral()
}

dependencies {
    testImplementation platform('org.junit:junit-bom:6.1.3')
    testImplementation 'org.junit.jupiter:junit-jupiter'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
    useJUnitPlatform()
}

The Kotlin DSL file, settings.gradle.kts (rootProject.name = "tidepool-model") and the Groovy twin (settings.gradle) were each run with gradle build and passed. The Kotlin DSL is the default for new builds; a Groovy DSL build behaves the same and several of the examples in this section show both. The testRuntimeOnly line for junit-platform-launcher is needed from Gradle 9, which no longer leaks its own copy of the launcher onto the test runtime class path. Without that line the test task of this module failed on Gradle 9.8.1 with the message Failed to load JUnit Platform. Please ensure that all JUnit Platform dependencies are available on the test's runtime classpath, including the JUnit Platform launcher.

Convention over configuration and the standard layout

Maven’s design goal is a uniform build: a developer who knows one Maven project can build another without reading its build file. The mechanism is convention over configuration: sensible defaults for everything that does not need to be decided, so a project only configures what is different. The most visible default is the directory layout, which Maven and Gradle share:

$ find src -type f | sort
src/main/java/io/tidepool/model/Height.java
src/test/java/io/tidepool/model/HeightTest.java

Sources in src/main/java, resources in src/main/resources, tests in src/test/java, and build output in target (Maven) or build (Gradle). The POM and the Gradle build file above never mention these directories, and both tools found the sources. Convention has a cost as well as a benefit: a project that does not fit the layout (generated sources, a legacy tree) has to configure its way out of it, and a build author needs to know the defaults to read a build that does not state them.

Ant has no convention: every directory is a property of the build file, as the src.dir, test.dir and build.dir lines above show. The Ant tutorial recommends a layout of src and build/classes for compiled output, and many projects copy the Maven layout in their Ant builds so that IDEs and people recognize it. The layout is the first thing to agree on when a project is moved from one tool to another, and why projects that follow it move easily; see Migrating Between Tools, Philosophy, Coordinates & Directory Layout and Java Projects.

Incremental builds and caching

A repeatable build is only practical if it is also fast when little has changed. Incremental build means skipping work whose inputs have not changed since it last ran; a build cache goes further and reuses outputs produced in an earlier build, possibly on another machine. The three tools differ in how much of this they do on their own.

Technique Ant Maven Gradle

Skip when up to date

per task: javac compares source and class file times; the uptodate task sets a property that a target tests with unless

per plugin: compiler detects changes and prints Nothing to compile; most other goals always run

for every task that declares inputs and outputs: UP-TO-DATE

Reuse of earlier outputs

none

the Maven Build Cache Extension (1.3.0), an extension configured in .mvn/extensions.xml

the build cache, switched on with --build-cache or org.gradle.caching=true: FROM-CACHE

What decides

file modification times

compiler plugin heuristics; for the extension, a checksum of the project inputs

a hash of the declared inputs (files, properties, classpath, task implementation)

Ant

Ant has no general incremental engine. Some tasks, javac among them, compare timestamps of sources and outputs; the uptodate task lets the build author do the same for any target. In the example jar.check sets the property jar.uptodate when the JAR is newer than every class file, and package carries unless="jar.uptodate". Running it a second time, the compile targets found nothing to compile and the JAR was not rebuilt, but the tests ran again because the test target has no such check (the summary lines of the launcher were dropped again):

$ ant package
Buildfile: /work/tidepool-model/build.xml

compile:

compile-tests:

test:
     [java]
     [java] Test run finished after 25 ms
     [java]

jar.check:

package:

BUILD SUCCESSFUL
Total time: 0 seconds

Timestamps are a weak signal: a changed build file or a new dependency version does not change any source time, so a stale output can survive. This is the reason many Ant builds begin with clean.

Maven

The compiler plugin keeps track of its own inputs, and the second build of an unchanged module found the classes up to date:

$ mvn -B package
[INFO] --- resources:3.5.0:resources (default-resources) @ tidepool-model ---
[INFO] skip non existing resourceDirectory /work/tidepool-model/src/main/resources
[INFO] --- compiler:3.16.0:compile (default-compile) @ tidepool-model ---
[INFO] Nothing to compile - all classes are up to date.
[INFO] --- resources:3.5.0:testResources (default-testResources) @ tidepool-model ---
[INFO] skip non existing resourceDirectory /work/tidepool-model/src/test/resources
[INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ tidepool-model ---
[INFO] Nothing to compile - all classes are up to date.
[INFO] --- surefire:3.6.0:test (default-test) @ tidepool-model ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running io.tidepool.model.HeightTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.027 s -- in io.tidepool.model.HeightTest
[INFO] Results:
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO] --- jar:3.5.1:jar (default-jar) @ tidepool-model ---
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS

surefire:test ran again, and jar:jar did not rebuild the JAR. Maven’s own core does not track inputs and outputs, so plugins decide for themselves. The Maven Build Cache Extension adds a project-level cache. It is a core extension, loaded from .mvn/extensions.xml, which this module declared with version 1.3.0 (it needs no other configuration to start):

<extensions xmlns="http://maven.apache.org/EXTENSIONS/1.0.0"
            xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
            xsi:schemaLocation="http://maven.apache.org/EXTENSIONS/1.0.0 https://maven.apache.org/xsd/core-extensions-1.0.0.xsd">
  <extension>
    <groupId>org.apache.maven.extensions</groupId>
    <artifactId>maven-build-cache-extension</artifactId>
    <version>1.3.0</version>
  </extension>
</extensions>

Running mvn clean verify twice against a cache directory inside the project (-Dmaven.build.cache.location), the first build computed a checksum of the inputs, found no earlier result and saved its own, and the second restored the whole module and skipped every goal, including the tests. The lines below are the cache-related lines of each log. The JDK 26 run also printed sun.misc.Unsafe deprecation warnings that come from the hashing library used by the extension, and they were left out:

$ mvn -B -Dmaven.build.cache.location=.build-cache clean verify    (first run)
[INFO] Cache is INITIALIZED on project level for io.tidepool:tidepool-model
[INFO] --- clean:3.5.0:clean (default-clean) @ tidepool-model ---
[INFO] Attempting to restore project io.tidepool:tidepool-model from build cache
[INFO] Local build was not found by checksum ac3dacfb904b0277 for io.tidepool:tidepool-model
[INFO] --- resources:3.5.0:resources (default-resources) @ tidepool-model ---
[INFO] --- compiler:3.16.0:compile (default-compile) @ tidepool-model ---
[INFO] --- resources:3.5.0:testResources (default-testResources) @ tidepool-model ---
[INFO] --- compiler:3.16.0:testCompile (default-testCompile) @ tidepool-model ---
[INFO] --- surefire:3.6.0:test (default-test) @ tidepool-model ---
[INFO] --- jar:3.5.1:jar (default-jar) @ tidepool-model ---
[INFO] Saved Build to local file: /work/tidepool-model/.build-cache/v1.2/io.tidepool/tidepool-model/ac3dacfb904b0277/local/buildinfo.xml
[INFO] BUILD SUCCESS

$ mvn -B -Dmaven.build.cache.location=.build-cache clean verify    (second run)
[INFO] Cache is INITIALIZED on project level for io.tidepool:tidepool-model
[INFO] --- clean:3.5.0:clean (default-clean) @ tidepool-model ---
[INFO] Attempting to restore project io.tidepool:tidepool-model from build cache
[INFO] Local build found by checksum ac3dacfb904b0277
[INFO] Found cached build, restoring io.tidepool:tidepool-model from cache by checksum ac3dacfb904b0277
[INFO] Skipping plugin execution (cached): resources:resources
[INFO] Skipping plugin execution (cached): compiler:compile
[INFO] Skipping plugin execution (cached): resources:testResources
[INFO] Skipping plugin execution (cached): compiler:testCompile
[INFO] Skipping plugin execution (cached): surefire:test
[INFO] Skipping plugin execution (cached): jar:jar
[INFO] BUILD SUCCESS

The extension is configurable (what counts as an input, which goals are cached, a remote cache) and has its own page: Build Performance: Cache, Parallel & mvnd.

Gradle

Gradle compares the declared inputs and outputs of every task with what it recorded for the previous run. A task whose inputs and outputs are unchanged is UP-TO-DATE, and a task without inputs or without sources reports NO-SOURCE. The first run below followed clean, and the second run changed nothing:

$ gradle --console=plain clean build
> Task :clean
> Task :compileJava
> Task :processResources NO-SOURCE
> Task :classes
> Task :jar
> Task :assemble
> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses
> Task :test
> Task :check
> Task :build
BUILD SUCCESSFUL in 927ms
5 actionable tasks: 5 executed

$ gradle --console=plain build
> Task :compileJava UP-TO-DATE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :jar UP-TO-DATE
> Task :assemble UP-TO-DATE
> Task :compileTestJava UP-TO-DATE
> Task :processTestResources NO-SOURCE
> Task :testClasses UP-TO-DATE
> Task :test UP-TO-DATE
> Task :check UP-TO-DATE
> Task :build UP-TO-DATE
BUILD SUCCESSFUL in 350ms
4 actionable tasks: 4 up-to-date

With the build cache switched on, outputs of cacheable tasks are stored under a key made from the task inputs. After a clean, the same inputs restore outputs from the cache instead of recomputing them. The excerpt is the second clean build with --build-cache; compileJava, compileTestJava and test came FROM-CACHE, while jar was executed, because the Jar task is not cacheable by default:

$ gradle --console=plain --build-cache clean build
> Task :clean
> Task :compileJava FROM-CACHE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :jar
> Task :assemble
> Task :compileTestJava FROM-CACHE
> Task :processTestResources NO-SOURCE
> Task :testClasses UP-TO-DATE
> Task :test FROM-CACHE
> Task :check UP-TO-DATE
> Task :build
BUILD SUCCESSFUL in 498ms
5 actionable tasks: 2 executed, 3 from cache

Gradle can only do this when a task declares its inputs and outputs, and a task that does not is always executed. A task written as ad hoc code in a build script is neither up to date nor cacheable until its author declares them. See Incremental Build and Build Cache.

Reproducible builds

A build is reproducible when the same sources, built with the same tools, give byte-for-byte identical artifacts. It matters for security (anyone can check that a published JAR came from a given commit) and for caching. The usual enemies are timestamps stored in archives, file order that depends on the file system, and absolute paths. The convention that lets a build use one fixed time is the SOURCE_DATE_EPOCH environment variable: seconds since the Unix epoch, typically the time of the last commit, which build tools are asked to use instead of the current time.

The experiment below builds the example JAR twice from clean, two seconds apart, and prints the first 16 hexadecimal digits of the SHA-256 of each result. Two runs with the same digits are a reproducible build. The script (the MVN_3_9_16 and MVN_3_10_0 variables stand for the two Maven installations) and its output are:

#!/bin/sh
# Build the JAR twice from clean, two seconds apart, and print the start of its SHA-256.
sum() { shasum -a 256 "$1" | cut -c1-16; }

for file in build.xml build-repro.xml; do                     # Ant 1.10.18
  for run in 1 2; do
    ant -q -f $file clean compile; sleep 2
    ant -q -f $file -Dbuild.timestamp=2026-10-01T00:00:00Z package
    echo "ant $file run $run: $(sum build/tidepool-model-1.0.0.jar)"
  done
done

for mvn in "$MVN_3_9_16" "$MVN_3_10_0"; do                    # two Maven installations
  for pom in pom-plain.xml pom.xml; do                        # without and with outputTimestamp
    for run in 1 2; do
      $mvn -q -B -f $pom clean package; sleep 2
      echo "maven $($mvn -v | head -1 | cut -d' ' -f3), $pom run $run: $(sum target/tidepool-model-1.0.0.jar)"
    done
  done
done

for run in 1 2; do                                            # Gradle 9.8.1
  gradle -q clean jar; sleep 2
  echo "gradle run $run: $(sum build/libs/tidepool-model-1.0.0.jar)"
done
ant build.xml run 1: 523013b6b8fabd19
ant build.xml run 2: 5c84764b25026670
ant build-repro.xml run 1: 43d565d242fdcc82
ant build-repro.xml run 2: 43d565d242fdcc82
maven 3.9.16, pom-plain.xml run 1: 20dc4473c4b1610d
maven 3.9.16, pom-plain.xml run 2: e3ad6feb814e6a58
maven 3.9.16, pom.xml run 1: 3d890192af10c7e1
maven 3.9.16, pom.xml run 2: 3d890192af10c7e1
maven 3.10.0, pom-plain.xml run 1: 3facb0ae3494ea76
maven 3.10.0, pom-plain.xml run 2: 3facb0ae3494ea76
maven 3.10.0, pom.xml run 1: 672aaceb76a65d93
maven 3.10.0, pom.xml run 2: 672aaceb76a65d93
gradle run 1: d434bb6f4265351a
gradle run 2: d434bb6f4265351a
  • Ant records the time of the files in the JAR, so two builds of the same sources differ (build.xml). The modificationtime attribute of jar and zip (since Ant 1.10.2) sets every entry to one time, and with it (build-repro.xml) the two builds were identical. Ant does not read SOURCE_DATE_EPOCH itself, so the build file takes the time from a property, -Dbuild.timestamp=..., that a script can fill in from the last commit. build-repro.xml is build.xml with the element <jar destfile="${jar.file}" basedir="${classes.dir}"/> extended by modificationtime="${build.timestamp}".

  • Maven switches plugins that support it (the JAR plugin among them) into reproducible mode when project.build.outputTimestamp is set, and writes that time into the archive entries. The value is an ISO 8601 date or a number of seconds, and release tooling can update it. On Maven 3.9.16 the property has to be set: without it (pom-plain.xml) the two builds differ, and with it (the pom.xml shown above, 2026-10-01T00:00:00Z) they are identical. Maven 3.10.0 gives the property a default in the Super POM, so builds are reproducible by default: the two builds of pom-plain.xml were identical and the JAR entries are dated 1980-02-01. A project still sets its own value, usually the time of the last commit, to record something meaningful. The digests of different Maven releases differ because each release binds different default plugin versions, so compare builds only within one release.

  • Gradle makes archives reproducible by default since 9.0: a fixed file order, fixed timestamps (the jar entries of the example are dated 1980-02-01) and fixed permissions, and the two builds were identical without any setting. A build can opt out with isPreserveFileTimestamps = true and isReproducibleFileOrder = false on the archive tasks. On Gradle 8.14 the archive tasks keep file system timestamps and file order unless the build sets isPreserveFileTimestamps = false and isReproducibleFileOrder = true on them (written from the documentation, not run here).

Identical bytes also need the same JDK: the META-INF/MANIFEST.MF that Ant wrote included Created-By: 26+35-2893 (Oracle Corporation), so a build on another JDK writes a different manifest, and may compile to different class files. The security side of this subject is on Supply Chain Security and Security & Reproducible Builds.

A short history

The tools arrived in answer to one another, and knowing the order explains many of their habits.

Timeline from 2000 to 2026 on three lanes for Ant and Ivy, Maven and Gradle, with the release of each major version and the Java version needed to run the current ones
  • Make (1970s) described a build as files that depend on files, with a shell command per rule. Its weakness for Java was portability: the commands are shell commands, and the file format is sensitive to tabs. The Ant manual names those two reasons as the motive for a new tool.

  • Ant began inside Tomcat and was split off in January 2000. Its first stand-alone release was 1.1 on 19 July 2000. It put the build in XML and the work in Java classes, which made it portable and easy to extend, and left each project to write its own targets. Ant 1.10 runs on Java 8 or later.

  • Maven started in the Jakarta Alexandria and Turbine projects to give projects a uniform build. Maven 1.0 was released on 13 July 2004 and Maven 2.0 on 20 October 2005. It introduced the POM, the lifecycle, convention over configuration and repositories with coordinates. Maven 3.0 followed on 8 October 2010.

  • Ivy added dependency management to Ant builds. It graduated as a subproject of Ant on 11 October 2007.

  • Gradle 1.0 was released on 12 June 2012: a Maven-style model (plugins, conventions, repositories) in a build language with real programming constructs, in Groovy. Gradle 3.0 (2016) added the first support for build scripts in Kotlin, and Gradle 5.0 (26 November 2018) made the Kotlin DSL production ready.

  • Now. Maven 3.10.0 (September 2026) still runs on Java 8 or later; Maven 4.0.0-rc-7 (release candidate) needs Java 17. Gradle 9.0 (31 July 2025) needs a JVM 17 or later to run, and Gradle 9.8.1 is the current release. Ant 1.10.18 was released in September 2026.

The decisions of the three tools, in the order of the timeline, are the reason the shared ideas on this page look so similar and the vocabularies so different. The verdict on when to use which is on Comparing Ant, Maven & Gradle.

Where older books are out of date

Books written in the early years of each tool are still useful for the ideas on this page, but several of their practices no longer apply, and the current form is the one shown here.

  • A 2002 book on Ant targets Ant 1.5 and a JDK 1.1 baseline. Ant 1.10 needs Java 8 or later, and a current javac rejects the old source="1.4" setting; the release attribute is the current way.

  • A 2009 book on Maven 2 assumes that Java 5 is enough and that plugin versions can be left out. Maven 3.9 and 3.10 need Java 8, Maven 4 needs Java 17, and the 3.10 super POM no longer manages plugin versions, so every plugin version should be pinned, as the example POM does for Surefire.

  • A 2014 book on Gradle 1.7 uses task name << { ... }, which was removed in Gradle 5.0 (use doLast, or tasks.register), and the compile, testCompile and runtime configurations, removed in 7.0 (use implementation, api and testImplementation). It runs on JDK 5, while Gradle 9 needs a JVM 17 or later and a daemon that is on by default since 3.0.

Where to go next