Gradle Incremental Build

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 Gradle 9.8.1 and was run on JDK 26 on 2026-10-16 (the daemon ran on JDK 26; no toolchain was involved, because the examples write small text files and do not compile code). Every example was run through the wrapper in a scratch copy of the tidepool project, once with Kotlin DSL build files and once with Groovy DSL build files, with --configuration-cache, and the console output is identical apart from the timings and from the class names that appear in a few messages (Build_gradle.StationReport for the Kotlin DSL, StationReport for the Groovy DSL). The scratch copy pointed its wrapper at a local copy of the same distribution archive; the properties file that a real project commits is

distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

The commands are shown as ./gradlew <task>; every recorded run also used --console=plain and --configuration-cache (the clean steps between the groups of runs did not). In the listings the lines about the configuration cache (Reusing configuration cache., Configuration cache entry stored. and the like) are omitted, and the project directory of the scratch copy is replaced by /work/tidepool. For the runs with --info only the part from the > Task line on is shown, because the lines before it describe the daemon and the watched directories. Every example is compatible with the configuration cache: the 55 recorded runs of each DSL reported no configuration cache problems. Features newer than Gradle 8.14 name their release; Gradle 8.14.x was not installed here, so statements about it come from the official documentation and are labelled "not run here".

Most of the time a build does the same work again and again for nothing: the sources did not change, so compiling them again would produce the same classes. Skipping that work is the single biggest source of build speed, and Gradle builds it into every task rather than into a few plugins. A task declares what it reads (its inputs) and what it writes (its outputs); before running it, Gradle compares both with what it recorded the last time and skips the task when nothing changed. That is the mechanism behind the UP-TO-DATE label that you see in nearly every build, and it is the reason why the declarations on a task matter more than the code inside it. The Ant answer is a comparison of time stamps that you write yourself (<uptodate>, or the built-in checks of a few tasks, see Ant Conditions & Control Flow); the Maven answer is plugin-specific staleness detection (see Maven Compiling & Toolchains) and the optional cache extension (see Maven Build Performance). The shared comparison is in Build Automation Fundamentals. For tasks and their outcomes see Gradle Tasks; for writing task classes see Writing Custom Tasks.

The decision Gradle makes for every task

Before a task runs, Gradle walks through the questions in the figure. The labels at the leaves are the task outcomes that the console prints (the full list is on Gradle Tasks); the figure is a simplification, because Gradle also takes the task graph and file system watching into account.

flowchart TD start["Task selected for execution"] --> onlyif{"All onlyIf predicates true?"} onlyif -- "no" --> skipped(["SKIPPED"]) onlyif -- "yes" --> empty{"Has skipWhenEmpty inputs, and are all of them empty?"} empty -- "yes" --> nosource(["NO-SOURCE"]) empty -- "no" --> fp["Fingerprint the inputs: task implementation, input properties, normalized input files"] fp --> same{"Inputs and outputs equal to the recorded execution, and every upToDateWhen true?"} same -- "yes" --> uptodate(["UP-TO-DATE"]) same -- "no" --> cache{"Build cache on, task cacheable, entry found for the input key?"} cache -- "yes" --> fromcache(["FROM-CACHE: outputs restored"]) cache -- "no" --> run["Run the actions, then record the new fingerprints"] run --> executed(["EXECUTED"])

Everything to the left of EXECUTED is work that is avoided, and every arrow into the decision boxes is something that a build author can influence: through the onlyIf conditions, through @SkipWhenEmpty, through the declared inputs and outputs and how they are normalized, through upToDateWhen, and through the build cache. The rest of the page follows the figure.

How Gradle compares

Gradle takes a fingerprint of the inputs before the first execution of a task. It contains the paths of the input files and a hash of the content of each file, the values of the input properties, and an identifier of the task implementation. After a successful run Gradle takes a fingerprint of the outputs (the set of output files and a hash of each) and stores both fingerprints. Before every later run it takes new fingerprints and compares them with the stored ones: if they are the same, the outputs are considered up to date and the task is skipped; if not, the task runs and the new fingerprints replace the old ones. The details that the official page spells out, and that explain surprising results:

  • The task code is an input. Gradle records the class name and an identifier of the classpath that holds the task implementation, the task actions and the nested inputs. When a task, its actions or its dependencies change between executions, the task is out of date. When Gradle cannot identify the implementation (a class loaded by a class loader that Gradle did not create, or a Java lambda, whose class name changes on every run) it disables caching for that task, so the task is never up to date and never comes from the build cache.

  • File stats are a shortcut. If the last modified time and the size of a file did not change, Gradle reuses the fingerprint of the previous run; a change that keeps both is not detected.

  • Order can matter. For an order-sensitive file property, such as a classpath, even a different order of the same files makes the task out of date. A plain @InputFiles property is not order sensitive.

  • Files added to an output directory are ignored. Files that appear in an output directory after the last run do not make the task out of date, so that unrelated tasks can share a directory. Files that were produced by the task and then changed or removed do. The section on incremental tasks below shows both.

  • A task needs at least one output. Without a declared output there is nothing to compare, and incremental build does not work (an incremental task without declared outputs has been an error since Gradle 6.0).

  • Non-deterministic tasks are a problem. A task that writes a time stamp or a random value into its output cannot be checked sensibly, because the outputs differ for the same inputs.

Two consequences follow for the way a build is written. If a property affects the output, it must be an input, otherwise the task is considered up to date when it is not. Conversely, a property that does not affect the output must not be an input, otherwise the task runs when it need not. The next sections show both mistakes with real output.

Declaring inputs and outputs with annotations

The recommended way is a task class with typed properties and an annotation on each getter. The official page gives the reason: the annotations are checked by Gradle and by the plugin development tooling, and they work for the build cache too. The class below belongs to the tidepool project. It reads the station list data/stations.csv, writes a small report, and declares what it reads and writes. It lives in the build script here so that the example is one file; the task class does not have to be declared there (see Writing Custom Tasks for buildSrc, build-logic and plugins).

Kotlin DSL
// build.gradle.kts
plugins {
    base
}

@CacheableTask
abstract class StationReport : DefaultTask() {
    @get:InputFile
    @get:PathSensitive(PathSensitivity.NONE)
    abstract val stationsFile: RegularFileProperty

    @get:Input
    abstract val title: Property<String>

    @get:Internal
    abstract val note: Property<String>

    @get:OutputFile
    abstract val reportFile: RegularFileProperty

    @TaskAction
    fun report() {
        val stations = stationsFile.get().asFile.readLines().drop(1).filter { it.isNotBlank() }
        val out = reportFile.get().asFile
        out.parentFile.mkdirs()
        out.writeText(buildString {
            appendLine(title.get())
            appendLine("stations: ${stations.size}")
            stations.forEach { appendLine("- " + it.split(",")[1]) }
        })
        println("report written (note: ${note.get()})")
    }
}

val report = tasks.register<StationReport>("report") {
    stationsFile = layout.projectDirectory.file("data/stations.csv")
    title = providers.gradleProperty("title").orElse("Tidepool stations")
    note = providers.gradleProperty("note").orElse("none")
    reportFile = layout.buildDirectory.file("reports/stations.txt")
}
Groovy DSL
// build.gradle
plugins {
    id 'base'
}

@CacheableTask
abstract class StationReport extends DefaultTask {
    @InputFile
    @PathSensitive(PathSensitivity.NONE)
    abstract RegularFileProperty getStationsFile()

    @Input
    abstract Property<String> getTitle()

    @Internal
    abstract Property<String> getNote()

    @OutputFile
    abstract RegularFileProperty getReportFile()

    @TaskAction
    void report() {
        def stations = stationsFile.get().asFile.readLines().drop(1).findAll { !it.isBlank() }
        def out = reportFile.get().asFile
        out.parentFile.mkdirs()
        out.text = ([title.get(), "stations: ${stations.size()}"] +
                stations.collect { '- ' + it.split(',')[1] }).join('\n') + '\n'
        println "report written (note: ${note.get()})"
    }
}

def report = tasks.register('report', StationReport) {
    stationsFile = layout.projectDirectory.file('data/stations.csv')
    title = providers.gradleProperty('title').orElse('Tidepool stations')
    note = providers.gradleProperty('note').orElse('none')
    reportFile = layout.buildDirectory.file('reports/stations.txt')
}

The four properties show the four decisions that every property needs:

  • stationsFile is a file the task reads, so it is an input file (@InputFile: a single file, not a directory). Gradle fingerprints its content. @PathSensitive(PathSensitivity.NONE) says that the location of the file does not matter, only the content, which matters for the build cache (the same file at another path gives the same cache key).

  • title changes the output, so it is an input value (@Input). Any Serializable type will do.

  • note is only informational (it appears in a console message, not in the report), so it is marked @Internal: it is neither an input nor an output, and changing it must not rerun the task.

  • reportFile is what the task produces (@OutputFile).

The annotation must sit on the getter. In the Kotlin DSL that is the @get: use-site target; in Groovy it can sit on the property or on the getter. Gradle ignores an annotation on a setter, or on a Java field that has no annotated getter. The Groovy examples declare abstract getters (getStationsFile()); Gradle generates the implementation, and the stationsFile = ... assignment in the build script still works.

Run it twice. The first run follows a clean, so it is a real execution; the second finds nothing to do:

./gradlew report
./gradlew report
> Task :report
report written (note: none)

BUILD SUCCESSFUL in 703ms
1 actionable task: 1 executed
> Task :report UP-TO-DATE

BUILD SUCCESSFUL in 701ms
1 actionable task: 1 up-to-date

Now append a station (SEA) to data/stations.csv. The run with --info explains why Gradle did not skip the task; it is the most useful debugging tool of this page:

echo "SEA,Seattle,47.6026,-122.3393,M2:1.0:10;K1:0.4:20" >> data/stations.csv
./gradlew report --info
> Task :report
Caching disabled for task ':report' because:
  Build cache is disabled
Task ':report' is not up-to-date because:
  Input property 'stationsFile' file /work/tidepool/data/stations.csv has changed.
report written (note: none)

BUILD SUCCESSFUL in 693ms
1 actionable task: 1 executed

Changing the title property from the command line changes the value of an input property. The message names the property:

./gradlew report -Ptitle=Stations-v2
> Task :report
report written (note: none)

BUILD SUCCESSFUL in 775ms
1 actionable task: 1 executed
./gradlew report -Ptitle=Stations-v3 --info
> Task :report
Caching disabled for task ':report' because:
  Build cache is disabled
Task ':report' is not up-to-date because:
  Value of input property 'title' has changed for task ':report'
report written (note: none)

BUILD SUCCESSFUL in 737ms
1 actionable task: 1 executed

Changing note, the @Internal property, does not make the task out of date (the println of the task is not shown because the task did not run):

./gradlew report -Ptitle=Stations-v3 -Pnote=hello
> Task :report UP-TO-DATE

BUILD SUCCESSFUL in 741ms
1 actionable task: 1 up-to-date

Outputs are inputs of the comparison as well. If someone deletes or edits the report, the task runs again to restore it:

rm build/reports/stations.txt
./gradlew report --info -Ptitle=Stations-v3
> Task :report
Caching disabled for task ':report' because:
  Build cache is disabled
Task ':report' is not up-to-date because:
  Output property 'reportFile' file /work/tidepool/build/reports/stations.txt has been removed.
report written (note: none)

BUILD SUCCESSFUL in 773ms
1 actionable task: 1 executed

The same happens when the output is edited, and the extra line explains why the build cache stayed out of it: a file that Gradle does not know how it was created cannot be restored from or stored in the cache.

echo extra >> build/reports/stations.txt
./gradlew report --info -Ptitle=Stations-v3
> Task :report
Caching disabled for task ':report' because:
  Build cache is disabled
  Gradle does not know how file 'build/reports/stations.txt' was created (output property 'reportFile'). Task output caching requires exclusive access to output paths to guarantee correctness (i.e. multiple tasks are not allowed to produce output in the same location).
Task ':report' is not up-to-date because:
  Output property 'reportFile' file /work/tidepool/build/reports/stations.txt has changed.
report written (note: none)

BUILD SUCCESSFUL in 768ms
1 actionable task: 1 executed
./gradlew report -Ptitle=Stations-v3
> Task :report UP-TO-DATE

BUILD SUCCESSFUL in 748ms
1 actionable task: 1 up-to-date

The last run shows the recovered state: after the previous execution rewrote the report, nothing differs and the task is up to date.

The annotations and what they change

The table is the up-to-date view of the annotations; the full reference with the supported property types is on the official page, and the list of the annotations for task authors is in Writing Custom Tasks.

Annotation Since Effect on the up-to-date check and the build cache key

@Input

0.9

The value is fingerprinted. Any Serializable value, and dependency resolution results wrapped in a Provider.

@InputFile, @InputDirectory

0.8

One file or directory (not the other kind). It must exist, unless the property is @Optional. @InputDirectory implies @IgnoreEmptyDirectories.

@InputFiles

0.8

Any collection of files and directories. The order of the files is not considered.

@Classpath

3.2

A runtime classpath: the order matters, the names of the entries do not, and archives are compared by their content, so that new time stamps or a different order of entries inside a JAR do not matter. Subject to the runtime classpath normalization described below.

@CompileClasspath

3.4

A compile classpath: only the ABI of the classes counts, so that a changed method body does not make a compile task out of date.

@OutputFile, @OutputDirectory

0.8

The output is fingerprinted too. @OutputDirectory creates the directory before the task runs.

@OutputFiles, @OutputDirectories

1.0

A map or an iterable of outputs. Using a file tree turns caching off for the task.

@Nested

0.9

A type that is not Serializable but has annotated properties of its own; the implementation of the nested object counts as well.

@Internal, @Console

3.0

Not an input and not an output; @Console marks a property that only changes the console output.

@Optional

0.8

The property may be absent; disables the validation of the value (a missing file is not an error).

@PathSensitive

3.1

Which part of a file path counts: ABSOLUTE, RELATIVE (relative to the root of the hierarchy), NAME_ONLY or NONE. The default for file inputs is ABSOLUTE.

@SkipWhenEmpty

0.8

If every @SkipWhenEmpty input is empty, the task is skipped and reported as NO-SOURCE. Implies @Incremental. Since Gradle 3.2 the previous outputs of such a task are removed when its inputs disappear.

@Incremental

5.4

Tracks the changes of the property so that InputChanges.getFileChanges() can report them (org.gradle.work.Incremental).

@IgnoreEmptyDirectories

6.8

Only changes in the content of directories count, not empty directories.

@NormalizeLineEndings

7.2

Unix and Windows line endings are treated as equal.

@Destroys, @LocalState

4.0 and 4.3

Files that the task removes, and files that are local state of the task (removed when the outputs are restored from the cache).

@UntrackedTask

7.3

The state of the task is not tracked at all (see below).

@CacheableTask

3.0

Allows the build cache to be used for the task (see the section on the build cache below).

The "Since" column is the Since entry of the 9.8.1 Javadoc of each annotation (for @NormalizeLineEndings it is Since: 7.2, which the Gradle 7.2 release notes confirm). It also dates old build scripts: @Input, @InputFile, @InputFiles, @InputDirectory, @Nested and the output annotations already existed in the Gradle 1.x line, while path sensitivity, classpath inputs, @Internal, @Console, untracked tasks and InputChanges are all newer than Gradle 1.7.

Path sensitivity

@PathSensitive controls how much of the path of an input file enters the fingerprint, and therefore how much moving files around makes the task out of date. PathSensitivity.ABSOLUTE (the default) takes the full path; RELATIVE takes the path relative to the root of the directory or collection and is the right choice for most source trees, because the same project checked out in two places then gets the same cache key; NAME_ONLY takes only the file name; NONE ignores paths entirely and is right for a single file whose location is irrelevant, as with stationsFile above. A too sensitive setting causes cache misses between machines; a too lax one hides a real change (for example a rename that matters to the task).

Classpath inputs and nested inputs

For JVM tasks Gradle understands two special kinds of input file collection. A @Classpath property is compared as a classpath: the order of the entries counts, but the names and paths of the entries do not, and the content of JAR files is compared without the time stamps and the order of the entries; a @CompileClasspath property additionally ignores everything but class files and, for those, everything that does not change the API (the same class analysis as in Java compile avoidance, see Java Projects). This is why a recompiled but identical library does not rebuild its consumers.

@Nested marks an object that has input and output properties of its own. Gradle uses the type of the actual value, so sub-types are covered; the type and classpath of a nested object are tracked as well, which means that a change to the implementation of a nested object (for example a user-provided Action) makes the task out of date. For a @Nested iterable every element is a separate nested input named $0, $1 and so on unless it implements Named (the order matters then); for a map the key is the name. The built-in task types use this for argument providers, for example Test.getJvmArgumentProviders(), which is how the JaCoCo agent declares its inputs.

Validation of the declarations

Gradle validates the declarations when the task is about to execute. The checks that the official page lists: an @InputFile must have a value and name an existing file (not a directory), an @InputDirectory the same for a directory, an @OutputDirectory must not name a file and is created if it does not exist, and a property without any annotation is a problem. A task with a validation problem is not optimized at all: it can never be up to date, can never be loaded from or stored in the build cache, never runs in parallel with other tasks and is never executed incrementally. Gradle 6.0 started to warn about such problems, and Gradle 7.0 made them errors that fail the build (as the run below shows); the warnings that were added later in the 7.x series became errors in Gradle 8.0. Two real examples. The first class forgets to annotate verbose:

Kotlin DSL
abstract class UnannotatedReport : DefaultTask() {
    @get:InputFile
    abstract val stationsFile: RegularFileProperty

    abstract val verbose: Property<Boolean>      // no annotation

    @get:OutputFile
    abstract val reportFile: RegularFileProperty

    @TaskAction
    fun report() {
        reportFile.get().asFile.apply { parentFile.mkdirs() }.writeText("verbose=${verbose.get()}\n")
    }
}

tasks.register<UnannotatedReport>("unannotated") {
    stationsFile = layout.projectDirectory.file("data/stations.csv")
    verbose = true
    reportFile = layout.buildDirectory.file("reports/unannotated.txt")
}
Groovy DSL
abstract class UnannotatedReport extends DefaultTask {
    @InputFile
    abstract RegularFileProperty getStationsFile()

    abstract Property<Boolean> getVerbose()      // no annotation

    @OutputFile
    abstract RegularFileProperty getReportFile()

    @TaskAction
    void report() {
        def out = reportFile.get().asFile
        out.parentFile.mkdirs()
        out.text = "verbose=${verbose.get()}\n"
    }
}

tasks.register('unannotated', UnannotatedReport) {
    stationsFile = layout.projectDirectory.file('data/stations.csv')
    verbose = true
    reportFile = layout.buildDirectory.file('reports/unannotated.txt')
}
./gradlew unannotated
[Incubating] Problems report is available at: file:///work/tidepool/build/reports/problems/problems-report.html

FAILURE: Build failed with an exception.

* What went wrong:
A problem was found with the configuration of task ':unannotated' (type 'Build_gradle.UnannotatedReport').
Missing annotation
  Type 'Build_gradle.UnannotatedReport' property 'verbose' is missing an input or output annotation
    Properties must be annotated so that Gradle knows how to handle them during up-to-date checking
    For more information, please refer to https://docs.gradle.org/9.8.1/userguide/validation_problems.html#missing_annotation in the Gradle documentation.
    Possible solutions:
      1. Add an input or output annotation.
      2. Mark it as @Internal.

* Try:
> Run with --scan to get full insights from a Build Scan (powered by Develocity).

BUILD FAILED in 847ms

The second configures the StationReport of above with a file that does not exist (the task registration is the same with data/missing.csv as the value of stationsFile):

./gradlew reportMissingInput
> Task :reportMissingInput FAILED

[Incubating] Problems report is available at: file:///work/tidepool/build/reports/problems/problems-report.html

FAILURE: Build failed with an exception.

* What went wrong:
A problem was found with the configuration of task ':reportMissingInput' (type 'Build_gradle.StationReport').
Input file does not exist
  Type 'Build_gradle.StationReport' property 'stationsFile' specifies file '/work/tidepool/data/missing.csv' which doesn't exist
    An input file was expected to be present but it doesn't exist
    For more information, please refer to https://docs.gradle.org/9.8.1/userguide/validation_problems.html#input_file_does_not_exist in the Gradle documentation.
    Possible solutions:
      1. Make sure the file exists before the task is called.
      2. Make sure that the task which produces the file is declared as an input.

* Try:
> Run with --scan to get full insights from a Build Scan (powered by Develocity).

BUILD FAILED in 819ms
1 actionable task: 1 executed

The problems report mentioned in the first line is an HTML file under build/reports/problems. Both messages carry a link to the page of the validation problem in the user manual, and the second one is the reason for @Optional: a file that may legitimately be absent is declared optional, which turns the check off.

The Java Gradle Plugin Development plugin runs a similar check on the task classes of a plugin project when it is built (see Testing & Publishing Plugins), which is why @Internal and @Console exist: they declare "neither input nor output" so that the check can tell an intentional omission from a forgotten annotation.

Declaring inputs and outputs with the runtime API

A task class is not always available: the task can be an ad-hoc task registered in a build script, or a task type from a plugin that you cannot annotate. Every task therefore has three objects, inputs (TaskInputs), outputs (TaskOutputs) and destroyables (TaskDestroyables), with methods that correspond one to one to the annotations. The official page says that the runtime API has almost feature parity with the annotations; what it lacks are @Nested, @Classpath, @CompileClasspath, @LocalState, @ReplacedBy and @Internal. It recommends the annotations wherever possible, and the API for the cases in which they cannot be used.

Annotation Runtime API

@Input

inputs.property(name, value), inputs.properties(map)

@InputFile, @InputDirectory, @InputFiles

inputs.file(path), inputs.dir(path), inputs.files(paths)

@OutputFile, @OutputDirectory

outputs.file(path), outputs.dir(path), outputs.files(paths), outputs.dirs(paths)

@Destroys

destroyables.register(paths)

@SkipWhenEmpty, @Optional, @PathSensitive, @IgnoreEmptyDirectories, @NormalizeLineEndings

The builder returned by inputs.files(): skipWhenEmpty(), optional(), withPathSensitivity(...), ignoreEmptyDirectories(), normalizeLineEndings(), withPropertyName(name); outputs have withPropertyName(name) and optional()

The classic bug of the ad-hoc task is the input that is read but never declared. The first task below declares an output and reads data/stations.csv, but does not declare the file as an input; the second declares both:

Kotlin DSL
tasks.register("stationCountStale") {
    val csv = layout.projectDirectory.file("data/stations.csv")
    val out = layout.buildDirectory.file("reports/count-stale.txt")
    outputs.file(out)                                   // output only: the CSV is not declared
    doLast {
        val n = csv.asFile.readLines().drop(1).count { it.isNotBlank() }
        out.get().asFile.apply { parentFile.mkdirs() }.writeText("$n\n")
        println("counted $n stations")
    }
}

tasks.register("stationCount") {
    val csv = layout.projectDirectory.file("data/stations.csv")
    val out = layout.buildDirectory.file("reports/count.txt")
    inputs.file(csv)
        .withPropertyName("stationsFile")
        .withPathSensitivity(PathSensitivity.NONE)
    outputs.file(out)
        .withPropertyName("countFile")
    doLast {
        val n = csv.asFile.readLines().drop(1).count { it.isNotBlank() }
        out.get().asFile.apply { parentFile.mkdirs() }.writeText("$n\n")
        println("counted $n stations")
    }
}
Groovy DSL
tasks.register('stationCountStale') {
    def csv = layout.projectDirectory.file('data/stations.csv')
    def out = layout.buildDirectory.file('reports/count-stale.txt')
    outputs.file(out)                                   // output only: the CSV is not declared
    doLast {
        def n = csv.asFile.readLines().drop(1).count { !it.isBlank() }
        out.get().asFile.with { parentFile.mkdirs(); text = "${n}\n" }
        println "counted ${n} stations"
    }
}

tasks.register('stationCount') {
    def csv = layout.projectDirectory.file('data/stations.csv')
    def out = layout.buildDirectory.file('reports/count.txt')
    inputs.file(csv)
        .withPropertyName('stationsFile')
        .withPathSensitivity(PathSensitivity.NONE)
    outputs.file(out)
        .withPropertyName('countFile')
    doLast {
        def n = csv.asFile.readLines().drop(1).count { !it.isBlank() }
        out.get().asFile.with { parentFile.mkdirs(); text = "${n}\n" }
        println "counted ${n} stations"
    }
}

Both tasks are run (after a clean), the station list gets a fourth station, and then both are run again:

./gradlew stationCountStale
echo "SEA,Seattle,47.6026,-122.3393,M2:1.0:10;K1:0.4:20" >> data/stations.csv
./gradlew stationCountStale
cat build/reports/count-stale.txt
./gradlew stationCount
cat build/reports/count.txt
./gradlew stationCount
> Task :stationCountStale
counted 3 stations

BUILD SUCCESSFUL in 744ms
1 actionable task: 1 executed
> Task :stationCountStale UP-TO-DATE

BUILD SUCCESSFUL in 734ms
1 actionable task: 1 up-to-date

After the edit, stationCountStale is UP-TO-DATE and its report still says

3

while the file now has four stations. The task with the declared input ran and produced the right number:

> Task :stationCount
counted 4 stations

BUILD SUCCESSFUL in 779ms
1 actionable task: 1 executed
4
> Task :stationCount UP-TO-DATE

BUILD SUCCESSFUL in 804ms
1 actionable task: 1 up-to-date

Two details of the runtime API are worth knowing. The values are examined by Gradle where they are declared, so file inputs and outputs can be added to a task from anywhere in the build as long as it happens in the configuration phase (this is how the from(...) method of Copy makes its arguments inputs). And if a task type already declares an input with annotations, registering a second one under the same property name is an error; give extra inputs a property name of their own, as in inputs.file("src/headers/headers.txt").withPropertyName("headers") for an extra file that a template task reads.

Inferred task dependencies

Once a task’s input is the output of another task, Gradle knows which one has to run first, so no dependsOn is needed. That is the reason why the declared inputs and outputs also build the task graph. A Zip task that packages the report is wired in two ways below: by passing the report task provider to from, and by passing the directory as a string:

Kotlin DSL
tasks.register<Zip>("packageReports") {
    archiveFileName = "reports.zip"
    destinationDirectory = layout.buildDirectory.dir("distributions")
    from(report)                 // the outputs of the report task: an inferred dependency
}

tasks.register<Zip>("packageReportsByPath") {
    archiveFileName = "reports-by-path.zip"
    destinationDirectory = layout.buildDirectory.dir("distributions")
    from("build/reports")        // a plain path: Gradle cannot know who writes it
}
Groovy DSL
tasks.register('packageReports', Zip) {
    archiveFileName = 'reports.zip'
    destinationDirectory = layout.buildDirectory.dir('distributions')
    from report                  // the outputs of the report task: an inferred dependency
}

tasks.register('packageReportsByPath', Zip) {
    archiveFileName = 'reports-by-path.zip'
    destinationDirectory = layout.buildDirectory.dir('distributions')
    from 'build/reports'         // a plain path: Gradle cannot know who writes it
}

After a clean, the path variant finds nothing to package, which Gradle reports as NO-SOURCE; the other variant first runs report:

./gradlew packageReportsByPath
> Task :packageReportsByPath NO-SOURCE

BUILD SUCCESSFUL in 701ms
./gradlew packageReports
> Task :report
report written (note: none)

> Task :packageReports

BUILD SUCCESSFUL in 677ms
2 actionable tasks: 2 executed
./gradlew packageReports
> Task :report UP-TO-DATE
> Task :packageReports UP-TO-DATE

BUILD SUCCESSFUL in 695ms
2 actionable tasks: 2 up-to-date

The rule to remember: anything that carries the task with it (a TaskProvider, a Provider obtained from a task property such as report.map { it.reportFile }, the result of files(), layout.files() or objects.fileCollection() applied to a task or to a task property) produces an inferred dependency. A string, a File, or the result of fileTree() does not; with a file tree you must add builtBy (or a dependsOn) yourself. The official page walks through the Java compilation and instrumentation case. The point of wiring outputs to inputs instead of writing dependsOn is also made on Gradle Tasks.

Gradle also uses the declarations in two more places. If one task writes a location and another task reads it without any dependency between them and both run at the same time, the build fails, to avoid capturing an incorrect state. And with --parallel Gradle will not run two tasks that write to the same output directory concurrently, nor a task that removes files (@Destroys) next to one that consumes or produces them. Continuous build (--continuous) also relies on the declared inputs to know what to watch; see Gradle Command-Line Interface (not run here).

Tasks without sources: NO-SOURCE

A task whose only job is to process input files has nothing to do when there are none. Marking the input with @SkipWhenEmpty (or skipWhenEmpty() in the runtime API) tells Gradle to skip the task when all such inputs are empty, and the console says NO-SOURCE instead of UP-TO-DATE. Since Gradle 3.2 the outputs of an earlier execution are then removed, so a deleted source does not leave a stale output behind. The task below archives the .txt files of an archive directory that does not exist yet:

Kotlin DSL
tasks.register("compressArchive") {
    inputs.files(fileTree("archive") { include("**/*.txt") })
        .skipWhenEmpty()
        .withPropertyName("archived")
        .withPathSensitivity(PathSensitivity.RELATIVE)
        .ignoreEmptyDirectories()
    val outDir = layout.buildDirectory.dir("archive-out")
    outputs.dir(outDir)
    doLast {
        outDir.get().file("archive.txt").asFile.apply { parentFile.mkdirs() }.writeText("archived\n")
        println("compressing the archive")
    }
}
Groovy DSL
tasks.register('compressArchive') {
    inputs.files(fileTree('archive') { include '**/*.txt' })
        .skipWhenEmpty()
        .withPropertyName('archived')
        .withPathSensitivity(PathSensitivity.RELATIVE)
        .ignoreEmptyDirectories()
    def outDir = layout.buildDirectory.dir('archive-out')
    outputs.dir(outDir)
    doLast {
        outDir.get().file('archive.txt').asFile.with { parentFile.mkdirs(); text = 'archived\n' }
        println 'compressing the archive'
    }
}

The sequence: no archive directory, then one file in it (run twice), then the file removed (ls build/archive-out after the last run), and the removal run once more:

./gradlew compressArchive
mkdir -p archive && echo old > archive/2025.txt
./gradlew compressArchive
./gradlew compressArchive
rm archive/2025.txt
./gradlew compressArchive
ls build/archive-out
./gradlew compressArchive
> Task :compressArchive NO-SOURCE

BUILD SUCCESSFUL in 675ms
> Task :compressArchive
compressing the archive

BUILD SUCCESSFUL in 682ms
1 actionable task: 1 executed
> Task :compressArchive UP-TO-DATE

BUILD SUCCESSFUL in 789ms
1 actionable task: 1 up-to-date
> Task :compressArchive

BUILD SUCCESSFUL in 674ms
1 actionable task: 1 executed
ls: build/archive-out: No such file or directory
> Task :compressArchive NO-SOURCE

BUILD SUCCESSFUL in 675ms

When the last input disappeared, the run printed no outcome label: Gradle deleted the stale output directory (the ls fails because build/archive-out is gone) and the task counted as executed without running its action. From then on the task is NO-SOURCE. @IgnoreEmptyDirectories is there because, since Gradle 8.0, a file tree no longer ignores directories on its own; without it an empty archive/2024 directory would be a source.

Incremental tasks

Up-to-date checking is all or nothing: if one input changed, the whole task runs again. For tasks that transform many files one to one, such as an image converter or a code generator, that wastes time when one file out of a thousand changed. An incremental task receives the list of the input files that were added, modified or removed since the last execution and processes only those. It is a separate feature from incremental build, and it builds on it: the task must still declare its inputs and outputs.

A task is incremental when it has a task action with a single InputChanges parameter, and declares at least one incremental file input with @Incremental or @SkipWhenEmpty. The tidepool example converts one text file per station into an upper-case copy in the output directory and keeps the output directory in step with the input directory:

Kotlin DSL
@CacheableTask
abstract class NormalizeStations : DefaultTask() {
    @get:Incremental
    @get:PathSensitive(PathSensitivity.NAME_ONLY)
    @get:InputDirectory
    abstract val inputDir: DirectoryProperty

    @get:OutputDirectory
    abstract val outputDir: DirectoryProperty

    @get:Input
    abstract val prefix: Property<String>

    @TaskAction
    fun execute(inputChanges: InputChanges) {
        println(
            if (inputChanges.isIncremental) "Executing incrementally"
            else "Executing non-incrementally"
        )
        inputChanges.getFileChanges(inputDir).forEach { change ->
            if (change.fileType == FileType.DIRECTORY) return@forEach
            println("${change.changeType}: ${change.normalizedPath}")
            val target = outputDir.file(change.normalizedPath).get().asFile
            if (change.changeType == ChangeType.REMOVED) {
                target.delete()
            } else {
                target.writeText(prefix.get() + change.file.readText().uppercase())
            }
        }
    }
}

tasks.register<NormalizeStations>("normalizeStations") {
    inputDir = layout.projectDirectory.dir("stations")
    outputDir = layout.buildDirectory.dir("normalized")
    prefix = providers.gradleProperty("prefix").orElse("TIDEPOOL ")
}
Groovy DSL
@CacheableTask
abstract class NormalizeStations extends DefaultTask {
    @Incremental
    @PathSensitive(PathSensitivity.NAME_ONLY)
    @InputDirectory
    abstract DirectoryProperty getInputDir()

    @OutputDirectory
    abstract DirectoryProperty getOutputDir()

    @Input
    abstract Property<String> getPrefix()

    @TaskAction
    void execute(InputChanges inputChanges) {
        println(inputChanges.incremental
            ? 'Executing incrementally'
            : 'Executing non-incrementally')
        inputChanges.getFileChanges(inputDir).each { change ->
            if (change.fileType == FileType.DIRECTORY) return
            println "${change.changeType}: ${change.normalizedPath}"
            def target = outputDir.file(change.normalizedPath).get().asFile
            if (change.changeType == ChangeType.REMOVED) {
                target.delete()
            } else {
                target.text = prefix.get() + change.file.text.toUpperCase()
            }
        }
    }
}

tasks.register('normalizeStations', NormalizeStations) {
    inputDir = layout.projectDirectory.dir('stations')
    outputDir = layout.buildDirectory.dir('normalized')
    prefix = providers.gradleProperty('prefix').orElse('TIDEPOOL ')
}

The input directory stations holds SF.txt, BOS.txt and HNL.txt with one line each. InputChanges.getFileChanges() returns one entry per changed file with the file, the change type (ADDED, MODIFIED or REMOVED), the file type and the normalized path (relative to the directory here, because of the path sensitivity). The action must write output for out-of-date inputs and delete the output of removed ones; a task has at most one incremental action. To query the changes of a property it has to return the same instance every time, which is the case for RegularFileProperty, DirectoryProperty and ConfigurableFileCollection.

The first run has no history, so everything counts as added and the task is not incremental. A second run finds nothing to do. Then BOS.txt is edited and SEA.txt added, then HNL.txt is removed:

./gradlew normalizeStations
./gradlew normalizeStations
echo "Seattle: M2 1.00 m" > stations/SEA.txt
echo "Boston: M2 1.40 m" > stations/BOS.txt
./gradlew normalizeStations
rm stations/HNL.txt
./gradlew normalizeStations
> Task :normalizeStations
Executing non-incrementally
ADDED: BOS.txt
ADDED: HNL.txt
ADDED: SF.txt

BUILD SUCCESSFUL in 688ms
1 actionable task: 1 executed
> Task :normalizeStations UP-TO-DATE

BUILD SUCCESSFUL in 673ms
1 actionable task: 1 up-to-date
> Task :normalizeStations
Executing incrementally
MODIFIED: BOS.txt
ADDED: SEA.txt

BUILD SUCCESSFUL in 679ms
1 actionable task: 1 executed
> Task :normalizeStations
Executing incrementally
REMOVED: HNL.txt

BUILD SUCCESSFUL in 669ms
1 actionable task: 1 executed

Only the changed files are listed in the incremental runs. Gradle falls back to processing everything (all inputs reported as ADDED, and the previous outputs removed first) whenever it cannot tell what changed. The official page lists the cases:

  • there is no history from a previous execution, or the previous execution used another Gradle version;

  • an upToDateWhen criterion of the task returns false (this is what --rerun sets, see below);

  • an input property has changed;

  • a non-incremental input file property has changed;

  • one or more output files have changed since the previous execution.

The runs below show three of them in turn. A file that is added to the output directory does not matter (the task stays up to date); a deleted output file does; a changed input property does; and --rerun forces a full execution:

echo scratch > build/normalized/notes.txt
./gradlew normalizeStations
rm build/normalized/SF.txt
./gradlew normalizeStations --info
./gradlew normalizeStations -Pprefix=TP:
./gradlew normalizeStations --rerun
> Task :normalizeStations UP-TO-DATE

BUILD SUCCESSFUL in 662ms
1 actionable task: 1 up-to-date
> Task :normalizeStations
Caching disabled for task ':normalizeStations' because:
  Build cache is disabled
  Gradle does not know how file 'build/normalized/notes.txt' was created (output property 'outputDir'). Task output caching requires exclusive access to output paths to guarantee correctness (i.e. multiple tasks are not allowed to produce output in the same location).
Task ':normalizeStations' is not up-to-date because:
  Output property 'outputDir' file /work/tidepool/build/normalized/SF.txt has been removed.
The input changes require a full rebuild for incremental task ':normalizeStations'.
Executing non-incrementally
ADDED: BOS.txt
ADDED: SEA.txt
ADDED: SF.txt

BUILD SUCCESSFUL in 799ms
1 actionable task: 1 executed
> Task :normalizeStations
Executing non-incrementally
ADDED: BOS.txt
ADDED: SEA.txt
ADDED: SF.txt

BUILD SUCCESSFUL in 692ms
1 actionable task: 1 executed
> Task :normalizeStations
Executing non-incrementally
ADDED: BOS.txt
ADDED: SEA.txt
ADDED: SF.txt

BUILD SUCCESSFUL in 667ms
1 actionable task: 1 executed

The --info run names the cause: the output SF.txt was removed, and the message "The input changes require a full rebuild for incremental task" follows. The notes.txt file added to the output directory was not part of the task state, so it neither triggered the task nor was it deleted by the full execution (the examples delete it afterwards).

Three historical details. The first incremental API, IncrementalTaskInputs, was replaced by InputChanges in Gradle 5.4 (the new one can query the changes of each file property separately), deprecated in Gradle 7.5, and removed in Gradle 8.0; code from the Gradle 1.x and 2.x era that takes an IncrementalTaskInputs parameter does not compile on 9.8.1. Declaring an incremental task without outputs has been an error since Gradle 6.0. And a full execution of an incremental task removes the previous outputs first, so the action only has to produce the files for the inputs it is given.

Turning the checks off or replacing them

Three mechanisms change the decision of the figure, and they are easy to confuse.

Mechanism What it does Use it for

doNotTrackState(reason) or @UntrackedTask(because = "...") (Gradle 7.3)

Gradle does not track the state of the task: no fingerprints, no up-to-date check, no build cache. The task always runs.

A task wrapped around a tool that keeps its own state (Git, npm), output that Gradle cannot track (a database, a remote service, a directory the build does not own), or unreadable files in the inputs or outputs.

outputs.upToDateWhen { ... }

Adds a condition to the normal check: the task is up to date only if the check passes and the predicate returns true.

The state of something that is neither a file nor an input value, such as a schema version read from a database.

onlyIf { ... } (with a reason since Gradle 7.6)

Skips the task, labelled SKIPPED, when the predicate is false. Not related to inputs and outputs.

A condition on the environment or on a property: run this task only on CI, only when -Pdeploy is set.

The official page warns about the common mistake, using upToDateWhen where onlyIf is meant: if the reason to skip a task has nothing to do with its inputs and outputs, use onlyIf. It also warns that the checks should save time, not cost as much as the task itself, and that upToDateWhen still captures the task state, which is why Gradle 7.3 introduced doNotTrackState as a faster replacement for outputs.upToDateWhen { false }. The three tasks below, with a run of each:

Kotlin DSL
tasks.register("stamp") {
    val out = layout.buildDirectory.file("stamp.txt")
    doNotTrackState("The stamp is refreshed on every build")
    doLast {
        out.get().asFile.apply { parentFile.mkdirs() }.writeText(java.time.Instant.now().toString() + "\n")
        println("stamped")
    }
}

tasks.register("syncRemote") {
    val marker = layout.buildDirectory.file("remote-version.txt")
    val refresh = providers.gradleProperty("refresh")
    outputs.file(marker)
    outputs.upToDateWhen { !refresh.isPresent }
    doLast {
        marker.get().asFile.apply { parentFile.mkdirs() }.writeText("synced\n")
        println("synced with the remote")
    }
}

tasks.register("deploy") {
    val deployFlag = providers.gradleProperty("deploy")
    onlyIf("the property 'deploy' is set") { deployFlag.isPresent }
    doLast { println("deploying") }
}
Groovy DSL
tasks.register('stamp') {
    def out = layout.buildDirectory.file('stamp.txt')
    doNotTrackState('The stamp is refreshed on every build')
    doLast {
        out.get().asFile.with { parentFile.mkdirs(); text = java.time.Instant.now().toString() + '\n' }
        println 'stamped'
    }
}

tasks.register('syncRemote') {
    def marker = layout.buildDirectory.file('remote-version.txt')
    def refresh = providers.gradleProperty('refresh')
    outputs.file(marker)
    outputs.upToDateWhen { !refresh.present }
    doLast {
        marker.get().asFile.with { parentFile.mkdirs(); text = 'synced\n' }
        println 'synced with the remote'
    }
}

tasks.register('deploy') {
    def deployFlag = providers.gradleProperty('deploy')
    onlyIf("the property 'deploy' is set") { deployFlag.present }
    doLast { println 'deploying' }
}

An untracked task always runs, and --info says why:

./gradlew stamp
> Task :stamp
stamped

BUILD SUCCESSFUL in 706ms
1 actionable task: 1 executed
./gradlew stamp --info
> Task :stamp
Caching disabled for task ':stamp' because:
  Build cache is disabled
Task ':stamp' is not up-to-date because:
  Task is untracked because: The stamp is refreshed on every build
stamped

BUILD SUCCESSFUL in 653ms
1 actionable task: 1 executed

The syncRemote task is up to date on the second run and runs when -Prefresh is given, although neither its inputs nor its output changed:

./gradlew syncRemote
> Task :syncRemote
synced with the remote

BUILD SUCCESSFUL in 781ms
1 actionable task: 1 executed
./gradlew syncRemote
> Task :syncRemote UP-TO-DATE

BUILD SUCCESSFUL in 710ms
1 actionable task: 1 up-to-date
./gradlew syncRemote -Prefresh
> Task :syncRemote
synced with the remote

BUILD SUCCESSFUL in 683ms
1 actionable task: 1 executed

The deploy task is skipped, and the reason appears with --info, until the property is set:

./gradlew deploy
> Task :deploy SKIPPED

BUILD SUCCESSFUL in 635ms
./gradlew deploy --info
> Task :deploy SKIPPED
Skipping task ':deploy' as task onlyIf 'the property 'deploy' is set' is false.

BUILD SUCCESSFUL in 647ms
./gradlew deploy -Pdeploy
> Task :deploy
deploying

BUILD SUCCESSFUL in 675ms
1 actionable task: 1 executed

The conditions are captured in providers (refresh, deployFlag) instead of reading project properties inside the lambda, which keeps them compatible with the configuration cache. For a task class, @UntrackedTask(because = "Git tracks the state") on the class is the equivalent of doNotTrackState. With the build cache, FROM-CACHE can only happen for a tracked task.

Input normalization

Before comparing two values of a classpath input, Gradle normalizes them: it extracts the ABI from class files for a compile classpath, and for a runtime classpath it ignores what cannot influence the behaviour, such as the time stamps and the order of the entries in a JAR. The normalization applies to every ZIP file on the classpath (JAR, WAR, AAR, APK) including those nested in directories or in other archives. All properties annotated @Classpath are runtime classpaths, and their normalization can be customized in the normalization block of the project that consumes them. This is the way to stop a file that changes on every build, such as a build-info.properties with the time stamp of the build, from making the tests run again and from causing build cache misses even though it has no effect on what the tests check.

The scratch project has a task describeDir with a @Classpath input that points at a directory runtime, and a task describeJar that does the same for a JAR built from that directory. The directory holds stations.properties and build-info.properties:

# runtime/build-info.properties
timestamp=2026-10-16T10:00:00Z
ci.job=42
Kotlin DSL
abstract class DescribeClasspath : DefaultTask() {
    @get:Classpath
    abstract val runtimeFiles: ConfigurableFileCollection

    @get:OutputFile
    abstract val outFile: RegularFileProperty

    @TaskAction
    fun describe() {
        outFile.get().asFile.apply { parentFile.mkdirs() }.writeText("entries: ${runtimeFiles.files.size}\n")
        println("described ${runtimeFiles.files.size} classpath entry")
    }
}

tasks.register<DescribeClasspath>("describeDir") {
    runtimeFiles.from(layout.projectDirectory.dir("runtime"))
    outFile = layout.buildDirectory.file("describe-dir.txt")
}

val buildInfoJar = tasks.register<Jar>("buildInfoJar") {
    archiveFileName = "build-info.jar"
    destinationDirectory = layout.buildDirectory.dir("libs")
    from(layout.projectDirectory.dir("runtime"))
    manifest {
        attributes("Implementation-Version" to providers.gradleProperty("buildVersion").orElse("1.0"))
    }
}

tasks.register<DescribeClasspath>("describeJar") {
    runtimeFiles.from(buildInfoJar)
    outFile = layout.buildDirectory.file("describe-jar.txt")
}

// switched on with -Pnormalize so that the examples can compare both behaviours
if (providers.gradleProperty("normalize").isPresent) {
    normalization {
        runtimeClasspath {
            properties("**/build-info.properties") {
                ignoreProperty("timestamp")
            }
            metaInf {
                ignoreAttribute("Implementation-Version")
            }
        }
    }
}
Groovy DSL
abstract class DescribeClasspath extends DefaultTask {
    @Classpath
    abstract ConfigurableFileCollection getRuntimeFiles()

    @OutputFile
    abstract RegularFileProperty getOutFile()

    @TaskAction
    void describe() {
        def n = runtimeFiles.files.size()
        outFile.get().asFile.with { parentFile.mkdirs(); text = "entries: ${n}\n" }
        println "described ${n} classpath entry"
    }
}

tasks.register('describeDir', DescribeClasspath) {
    runtimeFiles.from(layout.projectDirectory.dir('runtime'))
    outFile = layout.buildDirectory.file('describe-dir.txt')
}

def buildInfoJar = tasks.register('buildInfoJar', Jar) {
    archiveFileName = 'build-info.jar'
    destinationDirectory = layout.buildDirectory.dir('libs')
    from layout.projectDirectory.dir('runtime')
    manifest {
        attributes('Implementation-Version': providers.gradleProperty('buildVersion').orElse('1.0'))
    }
}

tasks.register('describeJar', DescribeClasspath) {
    runtimeFiles.from(buildInfoJar)
    outFile = layout.buildDirectory.file('describe-jar.txt')
}

// switched on with -Pnormalize so that the examples can compare both behaviours
if (providers.gradleProperty('normalize').present) {
    normalization {
        runtimeClasspath {
            properties('**/build-info.properties') {
                ignoreProperty 'timestamp'
            }
            metaInf {
                ignoreAttribute 'Implementation-Version'
            }
        }
    }
}

First without a rule. Run the task, run it again, then reorder the lines of stations.properties and add a comment (nothing changes in meaning), then change the time stamp in build-info.properties:

./gradlew describeDir
./gradlew describeDir
printf 'unit=m\n\n# reordered and re-commented\ndatum=MLLW\n' > runtime/stations.properties
./gradlew describeDir
printf 'timestamp=2026-10-16T11:30:00Z\nci.job=42\n' > runtime/build-info.properties
./gradlew describeDir
> Task :describeDir
described 1 classpath entry

BUILD SUCCESSFUL in 715ms
1 actionable task: 1 executed
> Task :describeDir UP-TO-DATE

BUILD SUCCESSFUL in 639ms
1 actionable task: 1 up-to-date
> Task :describeDir UP-TO-DATE

BUILD SUCCESSFUL in 686ms
1 actionable task: 1 up-to-date
> Task :describeDir
described 1 classpath entry

BUILD SUCCESSFUL in 661ms
1 actionable task: 1 executed

The reordered file is still UP-TO-DATE because properties files are normalized by default: Gradle loads them and compares the individual properties, ignoring comments, whitespace and the order. The new time stamp is a real change of a property and runs the task. With the rule from the build file switched on (-Pnormalize), the first execution under the new rule runs the task once, because the stored fingerprint was taken under the old rule; after that a new time stamp is ignored, and another property still counts:

printf 'timestamp=2026-10-16T12:00:00Z\nci.job=42\n' > runtime/build-info.properties
./gradlew describeDir -Pnormalize
printf 'timestamp=2026-10-16T13:00:00Z\nci.job=42\n' > runtime/build-info.properties
./gradlew describeDir -Pnormalize
printf 'timestamp=2026-10-16T13:00:00Z\nci.job=43\n' > runtime/build-info.properties
./gradlew describeDir -Pnormalize
> Task :describeDir
described 1 classpath entry

BUILD SUCCESSFUL in 813ms
1 actionable task: 1 executed
> Task :describeDir UP-TO-DATE

BUILD SUCCESSFUL in 734ms
1 actionable task: 1 up-to-date
> Task :describeDir
described 1 classpath entry

BUILD SUCCESSFUL in 685ms
1 actionable task: 1 executed

The JAR variant exercises the META-INF rules. The manifest of build-info.jar carries an Implementation-Version. A new version changes the JAR, so buildInfoJar runs, and without a rule describeJar runs as well; with ignoreAttribute it does not. In the third run the rule is new, so the task runs once, and in the fourth the JAR changes again but describeJar is up to date:

./gradlew describeJar
./gradlew describeJar -PbuildVersion=2.0
./gradlew describeJar -PbuildVersion=3.0 -Pnormalize
./gradlew describeJar -PbuildVersion=4.0 -Pnormalize
> Task :buildInfoJar

> Task :describeJar
described 1 classpath entry

BUILD SUCCESSFUL in 877ms
2 actionable tasks: 2 executed
> Task :buildInfoJar

> Task :describeJar
described 1 classpath entry

BUILD SUCCESSFUL in 689ms
2 actionable tasks: 2 executed
> Task :buildInfoJar

> Task :describeJar
described 1 classpath entry

BUILD SUCCESSFUL in 737ms
2 actionable tasks: 2 executed
> Task :buildInfoJar
> Task :describeJar UP-TO-DATE

BUILD SUCCESSFUL in 721ms
2 actionable tasks: 1 executed, 1 up-to-date

The rules available inside runtimeClasspath are:

Rule Effect

ignore("pattern")

The files that match the pattern are ignored on the runtime classpath. This is the way to drop a file like build-info.properties completely. It does not change what the tests can load, only what Gradle compares.

properties("pattern") { ignoreProperty("key") }

One property is ignored in the matching properties files. Without a pattern the rule applies to all properties files. A file that matches but cannot be loaded as a properties file is treated as an ordinary file (every whitespace and comment change counts).

metaInf { ignoreAttribute("name") }

A manifest attribute is ignored. Manifest files are normalized by default: comments, whitespace and order are ignored, and attribute names are compared case and order insensitively.

metaInf { ignoreProperty("key") }

A key is ignored in the properties files under META-INF.

metaInf { ignoreManifest() }

The whole META-INF/MANIFEST.MF is ignored.

metaInf { ignoreCompletely() }

Every file and directory below META-INF is ignored.

Do this in a convention plugin if the file is added to the JARs of every project, so that all consumers share the rule (see Convention Plugins & Sharing Build Logic). For compile classpaths, the rules above do not apply: Gradle uses the class analysis of Java compile avoidance. @NormalizeLineEndings and @IgnoreEmptyDirectories work at the level of a single property instead of the project.

Incremental build and the build cache

The same inputs that decide UP-TO-DATE form the key of the build cache. If the up-to-date check fails but the cache has an entry for the current inputs (for example because a clean checkout on a CI agent built them an hour ago), Gradle restores the outputs and reports FROM-CACHE. A task takes part when its class is annotated @CacheableTask (or cacheIf is used on it), its inputs are all declared, and its outputs are exclusively its own. The page Build Cache covers the cache itself; the short demonstration below uses a local cache directory configured in settings.gradle.kts (the Groovy version has the same block in settings.gradle):

Kotlin DSL
// settings.gradle.kts
rootProject.name = "tidepool"

buildCache {
    local {
        directory = File(rootDir, "build-cache")
    }
}
Groovy DSL
// settings.gradle
rootProject.name = 'tidepool'

buildCache {
    local {
        directory = new File(rootDir, 'build-cache')
    }
}

Both StationReport and NormalizeStations are @CacheableTask. The first run stores the outputs; after a clean the second run restores them:

./gradlew clean normalizeStations --build-cache
./gradlew clean normalizeStations --build-cache
> Task :clean

> Task :normalizeStations
Executing non-incrementally
ADDED: BOS.txt
ADDED: SEA.txt
ADDED: SF.txt

BUILD SUCCESSFUL in 709ms
2 actionable tasks: 2 executed
> Task :clean
> Task :normalizeStations FROM-CACHE

BUILD SUCCESSFUL in 698ms
2 actionable tasks: 1 executed, 1 from cache

The second run did not execute the task action (no "Executing" line). The cache key is built from the inputs, and -Dorg.gradle.caching.debug=true prints its parts. The output of one run, in the Kotlin DSL (the implementation line and the keys differ in the Groovy DSL because the class is a different one; the title fingerprint is the same):

./gradlew report --build-cache -Dorg.gradle.caching.debug=true --rerun -Ptitle=Stations-v3
> Task :report
Appending implementation to build cache key: Build_gradle$StationReport_Decorated@9982c96f14281d688a181707bb3b9eb9
Appending additional implementation to build cache key: Build_gradle$StationReport_Decorated@9982c96f14281d688a181707bb3b9eb9
Appending input value fingerprint for 'title' to build cache key: de6cb6dbd384d5f32edcb651de6dce72
Appending input file fingerprints for 'stationsFile' to build cache key: 83260b34dfbcab9dc082d94d5996a17e - IGNORED_PATH{/work/tidepool/data/stations.csv=IGNORED / e1c010c2aa0646191ae3dd9bb3627a78}
Appending output property name to build cache key: reportFile
Build cache key for task ':report' is f6c50650bfe7bb7f50a24a27195837af
report written (note: none)

BUILD SUCCESSFUL in 769ms
1 actionable task: 1 executed

The parts are the implementation of the task, the fingerprint of the title value, the fingerprint of the stationsFile content (the path is ignored because of PathSensitivity.NONE, which is why IGNORED_PATH appears), and the names of the outputs. When two machines get different keys for what should be the same build, comparing these lines is how you find the input that differs (see the page on the build cache and the official page on build cache debugging).

Debugging why a task ran

The decision is always explained, if you ask. The tools, in the order in which they are usually reached for:

  • --info prints "Task ':x' is not up-to-date because:" and the reason, as in the examples above: a changed input property, a changed input file, a removed or changed output, "Executed with '--rerun-tasks'", "Task.upToDateWhen is false" (what --rerun sets), "Task is untracked because", or a change of the task implementation. It also prints why caching was disabled.

  • --rerun (a task option, available on every task since Gradle 7.6) forces one task to run; --rerun-tasks forces all of them, without deleting outputs. Both are shown below for the report task.

  • -Dorg.gradle.caching.debug=true prints the cache key parts, as shown above.

  • A Build Scan (--scan) shows the same reasons and the inputs of every task in a web page (not run here, it needs the Develocity service; see the CLI page).

  • ./gradlew help --task <task> lists the options of a task, including --rerun.

./gradlew report -Ptitle=Stations-v3 --rerun --info
> Task :report
Caching disabled for task ':report' because:
  Build cache is disabled
Task ':report' is not up-to-date because:
  Task.upToDateWhen is false.
report written (note: none)

BUILD SUCCESSFUL in 777ms
1 actionable task: 1 executed
./gradlew report -Ptitle=Stations-v3 --rerun-tasks --info
> Task :report
Caching disabled for task ':report' because:
  Build cache is disabled
Task ':report' is not up-to-date because:
  Executed with '--rerun-tasks'.
report written (note: none)

BUILD SUCCESSFUL in 771ms
1 actionable task: 1 executed

A short list of the usual causes of unexpected re-executions and of unexpected skips follows. Each is connected to the examples above.

Symptom Usual cause Fix

The task is UP-TO-DATE but the output is wrong

A value or file that affects the output is read but not declared as an input (the stationCountStale task).

Declare it: @Input, @InputFile, inputs.file(...). Run once with --rerun to repair the output.

The task runs on every build

An input changes every time (a time stamp, a random value, a file written by the build itself), or the task is untracked, or it has no outputs, or upToDateWhen returns false.

Use --info. Normalize the input, remove the volatile property from the inputs (@Internal), or add the output.

The task runs on a clean checkout on CI although nothing relevant changed

PathSensitivity.ABSOLUTE (the default) on a location-independent input, or an absolute path used as an @Input value.

Use RELATIVE, NAME_ONLY or NONE; pass paths as files, not strings.

A task that depends on a generated file runs before it is generated

The input was given as a string or a file tree, so no dependency was inferred (the packageReportsByPath task).

Pass a task provider or a provider of the output; or add builtBy.

The build fails with "is missing an input or output annotation"

A getter of a task class has no annotation (the unannotated task).

Annotate it, or @Internal if it is neither an input nor an output.

The task is never FROM-CACHE

The task is not @CacheableTask, it is untracked, or the outputs overlap with another task. --info states the reason.

Make it cacheable, give it exclusive outputs, see the build cache page.

Stale outputs

When the Gradle version changes, Gradle detects that the outputs of tasks that ran with an older version need to be removed, so that the new version starts from a known state (not run here: it would need a second Gradle version). The cleanup of stale output directories is implemented only for the outputs of source sets (Java, Groovy and Scala compilation). Gradle 9.0.0 changed one detail: class files located outside the build directory are no longer deleted when they are stale, and the stale outputs inside the build directory are still cleaned up. The compressArchive example above is the other case of stale output removal: the outputs of a task that becomes NO-SOURCE.

The configuration cache and incremental build

The two features are independent and work together. The configuration cache saves the task graph and skips the configuration phase; the up-to-date check still happens for every task at execution time, with fresh fingerprints of the files. That is why the runs above that show UP-TO-DATE and FROM-CACHE also ran with the configuration cache on, and why a changed data/stations.csv re-ran report without any configuration cache invalidation: the file is a task input, not a configuration input. What is a configuration input, and therefore invalidates the stored graph, are the build files, Gradle properties and environment variables that were read during configuration; that is why every example reads them through providers (providers.gradleProperty(...)), which the cache tracks. All 55 recorded runs of each DSL reported zero configuration cache problems. See Configuration Cache & Isolated Projects for the requirements on task code.

Gradle 8.14 and Gradle 9

Nothing on this page needed a different API on Gradle 8.14.x (not run here; checked against the 8.14 and 9.x documentation): the annotations, InputChanges, doNotTrackState, onlyIf with a reason and the normalization block are all older than 8.14. What the upgrade guides record:

  • Gradle 6.0 warned about task validation problems and 7.0 turned them into errors; the warnings added later in the 7.x series became errors in 8.0.

  • Gradle 8.0 stopped ignoring directories for file trees with @SkipWhenEmpty automatically; add @IgnoreEmptyDirectories. @InputDirectory implies it.

  • The Gradle 8 upgrade notes deprecate boolean properties derived from a method with an is prefix and a Boolean return type. In Gradle 9.0.0 Groovy 4 no longer recognizes them as properties in build scripts, Gradle still considers them for up-to-date checks, and Gradle 10 will stop treating them as Gradle properties (announced, not released), which can change the behaviour of a task whose input is such a property. The fix is a new get method, with @Deprecated and @ReplacedBy on the old one.

  • Gradle 9.0.0 no longer deletes stale class files outside the build directory.

  • Gradle 9.0.0 builds archives reproducibly by default (fixed order and time stamps in Zip, Jar and Tar outputs), so archive outputs of unchanged inputs are byte-identical and stay stable as inputs of later tasks (see Working with Files).

  • Gradle 9.8.0 made the Copy and Sync destination lazy; see the same page.

Where the books are outdated

The Gradle book that the issue lists (Gradle in Action, written for Gradle 1.7) introduces incremental build with the early annotations and the early runtime API. The parts that are outdated or missing against 9.8.1:

  • IncrementalTaskInputs (the incremental API of that era) was replaced by InputChanges in Gradle 5.4, deprecated in 7.5, and removed in Gradle 8.0.

  • Path sensitivity, classpath inputs and normalization, @Internal, @IgnoreEmptyDirectories, @NormalizeLineEndings and untracked tasks did not exist in Gradle 1.7 (their versions are in the table above).

  • The build cache (Gradle 4.0) and the configuration cache (stable in 8.1) are newer than the book; FROM-CACHE is not an outcome the book knows. Task properties are now lazy Property types (see Lazy Configuration & Providers), not plain fields.

  • task name << { } (the old shorthand for adding an action) was removed in Gradle 5.0; use doLast { } and tasks.register.

  • Task validation problems fail the build since Gradle 7.0.

Equivalents in Ant and Maven

Question Gradle Maven Ant

How does the tool know it can skip a step?

Fingerprints of declared inputs and outputs, per task.

Each plugin decides (the compiler plugin recompiles the whole module when a source file was added, removed or changed; many plugins do not check at all). The optional Build Cache Extension adds guessed inputs.

Each task decides (most compare time stamps of source and target files); explicit checks use <uptodate>.

Who declares inputs and outputs?

The task author, with annotations, or the build author with the runtime API.

Nobody, apart from the cache extension’s configuration.

The build author, in the <uptodate> conditions.

What is compared?

Content hashes, with file stats as a shortcut.

Time stamps and class file state.

Time stamps.

Restoring earlier results

The build cache (FROM-CACHE).

The Build Cache Extension.

Not available.

Forcing a rebuild

--rerun, --rerun-tasks, clean.

clean, or the plugin’s own flag.

clean target, or removing the target files.

For the other two tools see Maven Compiling & Toolchains (incremental compilation and its classic bug), Maven Build Performance, Cache, Parallel & mvnd, Ant Conditions & Control Flow (uptodate) and Comparing Ant, Maven & Gradle.