Gradle Build Cache
|
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 (the current release on 2026-10-16). Every example was run through the Gradle wrapper on macOS with JDK 26 (Oracle java version "26"), which was both the JVM of the daemon and the JDK that compiled and tested the code, except in the one section that deliberately switches the JDK with a toolchain (JBR 21.0.10, found in the user’s JDK directory; automatic JDK downloads were switched off with org.gradle.java.installations.auto-download=false). The Kotlin DSL is shown first and the Groovy DSL second wherever the syntax differs, and both versions of the tidepool build were run with the same commands; the console output is the same apart from timings, from the order of independent tasks, from the names of script classes (Build_gradle.StationSummary in the Kotlin DSL against StationSummary in the Groovy DSL) and the hashes that depend on them, and from the differences named in the text. Every build ran with the configuration cache on (org.gradle.configuration-cache=true in gradle.properties) and reported no configuration cache problems, except the one example that is labelled as a configuration cache failure. A build cache example must start from an empty cache, so the examples ran with a scratch Gradle User Home (GRADLE_USER_HOME) of their own, whose caches/build-cache-1 directory was emptied before each group of runs; nothing of the real user home was read or deleted. The scratch project pointed its wrapper at a local copy of the 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
In the listings the lines about the configuration cache (Reusing configuration cache., Configuration cache entry stored. and the like) are left out, and the project directory of the scratch copy is replaced by /work/tidepool (a second checkout of the same project by /work/tidepool-b) and the scratch Gradle User Home by $GRADLE_USER_HOME. The commands are shown as ./gradlew <task> and every recorded run also used --console=plain. The JVM default locale on the machine was Spanish; no output on this page has decimals, but the dates in two listings are printed in the Java default format. The remote cache examples talk to a toy HTTP cache node of 72 lines that was started on 127.0.0.1 for the run; its code is on this page. Develocity, the Gradle Docker cache node and HTTPS were not run here, and Gradle 8.14.x was not installed, so everything said about 8.14 comes from the official documentation and is labelled "not run here".
Gradle can skip a task in two ways. The first is the up-to-date check: if the inputs and outputs of the task are the same as in the previous execution in the same workspace, the task is skipped with the outcome UP-TO-DATE (see Gradle Incremental Build). That memory is short: it holds the last execution of each task and nothing else, so switching a branch back and forth, running clean, or building the same commit in a second checkout all start from scratch. The second way is the build cache: Gradle stores the outputs of a task under a key computed from everything that influences them, and any later build, in any directory on the machine or, with a remote cache, on any machine, that computes the same key unpacks the stored outputs instead of running the task. That outcome is FROM-CACHE. The Ant answer to this problem is a time-stamp comparison that the build author writes (see Ant Conditions & Control Flow) and nothing that outlives the workspace; the Maven answer is an optional core extension, the Apache Maven Build Cache Extension (see Maven Build Performance). The shared vocabulary is on Build Automation Fundamentals. The reason why Gradle can do this reliably is the model described on Gradle Tasks and Writing Custom Tasks: every task declares what it reads and what it writes.
Four caches that are easily confused
Gradle has several things called a cache, and the build cache is only one of them. Most of the confusion about "the cache" in a Gradle question comes from mixing them up.
| Cache | What it stores | Where | Page |
|---|---|---|---|
Up-to-date history |
The fingerprints of the inputs and outputs of the last execution of each task |
|
|
Build cache |
The outputs of cacheable tasks, under a key computed from their inputs |
|
This page |
Configuration cache |
The result of the configuration phase (the task graph) |
|
|
Dependency cache |
Downloaded modules and their metadata ( |
|
The build cache is the only one of the four that is shared between checkouts and, if you configure a remote node, between machines; it is also the only one that can make a wrong result appear (a cache hit for a task whose inputs were not fully declared), which is why most of this page is about declaring inputs correctly.
Release history
Task output caching arrived in 2017 as an incubating feature and has been stable since the 4.x series; its configuration and its cleanup changed since. The dates are those of the Gradle versions list; the changes are from the release notes and upgrade guides of each version.
| Release (date) | Change |
|---|---|
3.3 (2017-01-03) |
Task output properties get unique names, which prepares Gradle for the build cache that followed. |
3.5 (2017-04-10) |
The build cache is introduced as an incubating feature: a local directory cache and a remote HTTP cache, task output caching. |
4.0 (2017-06-14) |
Production-ready for Java and Groovy compilation and Java test tasks; overlapping task outputs disable caching; configurable input normalization (ignore runtime classpath resources); the remote cache honours |
4.5 (2018-01-24) |
Included builds of a composite share the build cache configuration of the root build; C/C++ compilation caching stable; the ANTLR task becomes cacheable. |
5.0 (2018-11-26) |
|
6.0 (2019-11-08) |
Announced deprecation warning for pulling cache hits from a remote cache over insecure HTTP; tasks with incorrectly declared inputs or outputs are called out during the build. |
7.0 (2021-04-09) |
A task that fails input/output validation runs without parallel execution, up-to-date checks and the build cache. |
8.8 (2024-05-31) |
Cleanup of the local cache is configured like the other caches of the Gradle User Home, in an init script ( |
9.0.0 (2025-07-31) |
|
9.7.0 (2026-08-06) |
Kotlin DSL accessor generation is no longer stored in the build cache. |
The tidepool build used on this page
The examples use the three-project tidepool build from Gradle Java Projects (a model library, an engine library that reads a CSV file of stations, and a command-line application), reduced to what matters here: three projects, the Java compiler options and the JUnit Jupiter suite. The versions come from gradle/libs.versions.toml, the repositories from settings.gradle.kts, exactly as on that page, and gradle.properties holds the configuration cache switch.
// tidepool-model/build.gradle.kts
plugins {
`java-library`
}
java {
withSourcesJar()
withJavadocJar()
}
tasks.withType<JavaCompile>().configureEach {
options.release = 21
options.compilerArgs.add("-Xlint:all")
}
tasks.javadoc {
(options as StandardJavadocDocletOptions).addBooleanOption("Xdoclint:all,-missing", true)
}
tasks.jar {
manifest {
attributes(
"Implementation-Title" to project.name,
"Implementation-Version" to project.version,
)
}
}
testing {
suites {
named<JvmTestSuite>("test") {
useJUnitJupiter(libs.versions.junit)
}
}
}
// tidepool-model/build.gradle
plugins {
id 'java-library'
}
java {
withSourcesJar()
withJavadocJar()
}
tasks.withType(JavaCompile).configureEach {
options.release = 21
options.compilerArgs.add('-Xlint:all')
}
tasks.javadoc {
options.addBooleanOption('Xdoclint:all,-missing', true)
}
tasks.jar {
manifest {
attributes(
'Implementation-Title': project.name,
'Implementation-Version': project.version,
)
}
}
testing {
suites {
test {
useJUnitJupiter(libs.versions.junit)
}
}
}
The engine (java-library, api(project(":tidepool-model")), implementation(libs.commons.csv), the same compiler options and test suite) and the CLI (application, implementation(project(":tidepool-engine")), implementation(libs.picocli)) are as on that page. Their build.gradle.kts files are not repeated; the pieces this page adds to them are shown where they are used.
These files are this page’s own setup for the examples, not an example taken from the manual.
How a cached task execution works
When a task is about to execute, Gradle goes through the steps of the figure. The up-to-date check comes first and is purely local; the build cache is consulted only for a task that is not up to date, and only if the cache is enabled and the task is cacheable.
Three consequences follow from the figure and are worth keeping in mind. A task that is cached is still skipped for its actions, so anything it did as a side effect (printing, registering, writing outside its declared outputs) does not happen. A cache hit replaces the outputs on disk, so the result is the same as an execution if, and only if, the inputs were declared completely. And the order of the lookups matters for cost: local first, then remote, and an entry found remotely is copied into the local cache, so the second time it is found locally.
Enabling the build cache
The build cache is off by default. There are two switches, and the command line wins over the property.
| Switch | Effect |
|---|---|
|
Use the build cache for this build only. |
|
Use the build cache for every build that reads the file (the project, or the Gradle User Home for all projects), unless |
|
Do not use the build cache in this build, whatever the property says. |
|
Print how the build cache key of each task was computed (see the debugging section). |
These are the only intended switches. Turning the cache on from build logic, with gradle.startParameter.buildCacheEnabled = true in a settings file or an init script, is deprecated since Gradle 9.8.0 and will be removed in Gradle 10 (not run here).
Source: Gradle User Manual: Enable the Build Cache, Gradle User Manual: Upgrading within Gradle 9.x.y, deprecation of enabling the Build Cache from build logic
The first example is the one that the official documentation uses: a build without the cache, then with it. Without it, clean throws everything away and the next build recompiles and retests everything, even though nothing changed:
$ ./gradlew clean build
BUILD SUCCESSFUL in 820ms
20 actionable tasks: 16 executed, 4 up-to-date
$ ./gradlew build
BUILD SUCCESSFUL in 258ms
16 actionable tasks: 16 up-to-date
$ ./gradlew clean build
BUILD SUCCESSFUL in 818ms
20 actionable tasks: 20 executed
With the cache on, the first run executes the tasks and stores the outputs of the cacheable ones; after a clean, the second run unpacks them:
$ ./gradlew build --build-cache
BUILD SUCCESSFUL in 1s
16 actionable tasks: 16 executed
$ ./gradlew clean --quiet
$ ./gradlew build --build-cache
> Task :assemble UP-TO-DATE
> Task :check UP-TO-DATE
> Task :build UP-TO-DATE
> Task :tidepool-model:processResources NO-SOURCE
> Task :tidepool-model:processTestResources NO-SOURCE
> Task :tidepool-engine:processResources NO-SOURCE
> Task :tidepool-cli:processTestResources NO-SOURCE
> Task :tidepool-engine:processTestResources NO-SOURCE
> Task :tidepool-cli:processResources NO-SOURCE
> Task :tidepool-model:sourcesJar
> Task :tidepool-model:compileJava FROM-CACHE
> Task :tidepool-model:classes UP-TO-DATE
> Task :tidepool-model:compileTestJava FROM-CACHE
> Task :tidepool-model:jar
> Task :tidepool-model:testClasses UP-TO-DATE
> Task :tidepool-engine:compileJava FROM-CACHE
> Task :tidepool-engine:classes UP-TO-DATE
> Task :tidepool-engine:jar
> Task :tidepool-engine:assemble
> Task :tidepool-model:test FROM-CACHE
> Task :tidepool-model:check UP-TO-DATE
> Task :tidepool-engine:compileTestJava FROM-CACHE
> Task :tidepool-engine:testClasses UP-TO-DATE
> Task :tidepool-cli:compileJava FROM-CACHE
> Task :tidepool-cli:classes UP-TO-DATE
> Task :tidepool-cli:compileTestJava NO-SOURCE
> Task :tidepool-cli:testClasses UP-TO-DATE
> Task :tidepool-cli:test NO-SOURCE
> Task :tidepool-cli:check UP-TO-DATE
> Task :tidepool-cli:jar
> Task :tidepool-engine:test FROM-CACHE
> Task :tidepool-engine:check UP-TO-DATE
> Task :tidepool-engine:build
> Task :tidepool-cli:startScripts
> Task :tidepool-cli:distTar
> Task :tidepool-model:javadoc FROM-CACHE
> Task :tidepool-cli:distZip
> Task :tidepool-cli:assemble
> Task :tidepool-cli:build
> Task :tidepool-model:javadocJar
> Task :tidepool-model:assemble
> Task :tidepool-model:build
BUILD SUCCESSFUL in 417ms
16 actionable tasks: 8 executed, 8 from cache
The listing shows the whole vocabulary of task outcomes. FROM-CACHE is the build cache hit: the compilation tasks of the projects (compileJava, and compileTestJava where there are tests), the two test tasks and the javadoc task came out of the cache and their sources were not even handed to javac. The tasks without a label were executed: sourcesJar, jar, javadocJar, startScripts, distTar and distZip are archive and copy tasks, which Gradle deliberately does not cache (see the next sections). UP-TO-DATE marks lifecycle tasks (classes, check, build) that have no actions, and NO-SOURCE marks tasks whose inputs do not exist (the CLI has no tests). The summary line counts "8 executed, 8 from cache": eight of the sixteen actionable tasks were avoided.
The property form is what teams use, because every developer gets the cache without remembering a flag. --no-build-cache is the escape hatch:
$ cat gradle.properties
org.gradle.configuration-cache=true
group=io.tidepool
version=1.0.0
org.gradle.caching=true
$ ./gradlew clean build # first build, stores
BUILD SUCCESSFUL in 889ms
20 actionable tasks: 16 executed, 4 up-to-date
$ ./gradlew clean build # second build, no flag needed
BUILD SUCCESSFUL in 368ms
20 actionable tasks: 12 executed, 8 from cache
$ ./gradlew clean build --no-build-cache
BUILD SUCCESSFUL in 880ms
20 actionable tasks: 19 executed, 1 up-to-date
The official best-practices page recommends enabling the cache. Whether it also belongs in the user’s gradle.properties rather than the project’s is a policy decision: the project file turns it on for every contributor and for CI, which is usually what you want once the build is known to be cache-safe; the file in the Gradle User Home turns it on for one machine only.
The build cache key
The key is a 128-bit hash, written as 32 hexadecimal digits, computed for each cacheable task from the following ingredients. Two tasks whose keys are equal are the same function applied to the same arguments, whatever they are called and wherever they live.
| Ingredient | What it covers |
|---|---|
The task implementation |
The class of the task and the hash of the class loader that defined it (for a task class declared in a build script, the compiled script; for a plugin task, the plugin JAR). The Gradle version is part of this through the classpath of Gradle itself. |
The task action implementations |
The classes of the actions added with |
The names of the output properties |
|
The input properties |
The names and the values (hashed) of the properties annotated with |
The input files |
The names of the file properties and the fingerprint of their content, normalized according to the declared path sensitivity or classpath normalization (see below). |
Two things are deliberately not in the key. The task path is not (the documentation says so explicitly), so :tidepool-engine:stationSummary and a task of another project can reuse each other’s entries when everything else is the same; and the absolute location of the project is not, unless a property is declared with ABSOLUTE path sensitivity. Both are what make the cache useful across checkouts and also what make an incomplete declaration dangerous.
--info prints the key of every task that is considered for caching, and whether the entry was stored or loaded, and why a task was not cacheable. The first listing is the build of the model project with an empty cache, the second the same build after clean:
> Task :tidepool-model:processResources NO-SOURCE
> Task :tidepool-model:processTestResources NO-SOURCE
> Task :tidepool-model:sourcesJar
Caching disabled for task ':tidepool-model:sourcesJar' because:
Not worth caching
> Task :tidepool-model:compileJava
Build cache key for task ':tidepool-model:compileJava' is 0b767ff69ffa86da3753627d7ca3f6be
Stored cache entry for task ':tidepool-model:compileJava' with cache key 0b767ff69ffa86da3753627d7ca3f6be
> Task :tidepool-model:classes
> Task :tidepool-model:jar
Caching disabled for task ':tidepool-model:jar' because:
Not worth caching
> Task :tidepool-model:compileTestJava
Build cache key for task ':tidepool-model:compileTestJava' is 2a2be1f7c7e47566e5f52402aded39b1
Stored cache entry for task ':tidepool-model:compileTestJava' with cache key 2a2be1f7c7e47566e5f52402aded39b1
> Task :tidepool-model:testClasses
> Task :tidepool-model:test
Build cache key for task ':tidepool-model:test' is 8e5fdbed08e75ead5be1704197c1177f
Stored cache entry for task ':tidepool-model:test' with cache key 8e5fdbed08e75ead5be1704197c1177f
> Task :tidepool-model:check
> Task :tidepool-model:javadoc
Build cache key for task ':tidepool-model:javadoc' is 96768bcfbe3e490e2bed5c12966bccc1
Stored cache entry for task ':tidepool-model:javadoc' with cache key 96768bcfbe3e490e2bed5c12966bccc1
> Task :tidepool-model:javadocJar
Caching disabled for task ':tidepool-model:javadocJar' because:
Not worth caching
> Task :tidepool-model:assemble
> Task :tidepool-model:build
> Task :tidepool-model:processTestResources NO-SOURCE
> Task :tidepool-model:processResources NO-SOURCE
> Task :tidepool-model:sourcesJar
Caching disabled for task ':tidepool-model:sourcesJar' because:
Not worth caching
> Task :tidepool-model:compileJava FROM-CACHE
Build cache key for task ':tidepool-model:compileJava' is 0b767ff69ffa86da3753627d7ca3f6be
Loaded cache entry for task ':tidepool-model:compileJava' with cache key 0b767ff69ffa86da3753627d7ca3f6be
> Task :tidepool-model:classes UP-TO-DATE
> Task :tidepool-model:jar
Caching disabled for task ':tidepool-model:jar' because:
Not worth caching
> Task :tidepool-model:compileTestJava FROM-CACHE
Build cache key for task ':tidepool-model:compileTestJava' is 2a2be1f7c7e47566e5f52402aded39b1
Loaded cache entry for task ':tidepool-model:compileTestJava' with cache key 2a2be1f7c7e47566e5f52402aded39b1
> Task :tidepool-model:testClasses UP-TO-DATE
> Task :tidepool-model:test FROM-CACHE
Build cache key for task ':tidepool-model:test' is 8e5fdbed08e75ead5be1704197c1177f
Loaded cache entry for task ':tidepool-model:test' with cache key 8e5fdbed08e75ead5be1704197c1177f
> Task :tidepool-model:check UP-TO-DATE
> Task :tidepool-model:javadoc FROM-CACHE
Build cache key for task ':tidepool-model:javadoc' is 96768bcfbe3e490e2bed5c12966bccc1
Loaded cache entry for task ':tidepool-model:javadoc' with cache key 96768bcfbe3e490e2bed5c12966bccc1
> Task :tidepool-model:javadocJar
Caching disabled for task ':tidepool-model:javadocJar' because:
Not worth caching
> Task :tidepool-model:assemble
> Task :tidepool-model:build
The keys are the same in both runs, and they are the same in the Kotlin DSL and the Groovy DSL build, because JavaCompile, Test and Javadoc are Gradle tasks with identical inputs. The tasks that were not cached are explained on the lines Caching disabled for task ... because:; here the reason is always Not worth caching, which is what Gradle says for archive and copy tasks (sourcesJar, jar, javadocJar).
What an entry looks like
An entry is one file in $GRADLE_USER_HOME/caches/build-cache-1, named by the key. It is a gzip-compressed tar archive with a METADATA file and one tree-<property> entry per output property. The listing is of the cache after the first build above (sizes in bytes), followed by the contents of the entry for the model project’s compileJava, found by its key:
$ ls -l $GRADLE_USER_HOME/caches/build-cache-1
3111 0b767ff69ffa86da3753627d7ca3f6be
5639 2903f2d7209d1b749fbeffefcfbcd51c
2743 2a2be1f7c7e47566e5f52402aded39b1
6304 540501a12ca9522f09ca57a10e26e71b
6201 8e5fdbed08e75ead5be1704197c1177f
4140136 96768bcfbe3e490e2bed5c12966bccc1
3960 9daed805ee409c990c425e4e78e26d5c
17 build-cache-1.lock
6204 f9a68714a2f389d2c532a1ed4c2feb96
0 gc.properties
$ tar tzf $GRADLE_USER_HOME/caches/build-cache-1/0b767ff69ffa86da3753627d7ca3f6be
METADATA
tree-destinationDirectory/
tree-destinationDirectory/io/
tree-destinationDirectory/io/tidepool/
tree-destinationDirectory/io/tidepool/model/
tree-destinationDirectory/io/tidepool/model/Constituent.class
tree-destinationDirectory/io/tidepool/model/Height.class
tree-destinationDirectory/io/tidepool/model/Station.class
tree-destinationDirectory/io/tidepool/model/TideEvent.class
tree-options.generatedSourceOutputDirectory/
tree-options.headerOutputDirectory/
tree-previousCompilationData
$ tar xzOf $GRADLE_USER_HOME/caches/build-cache-1/0b767ff69ffa86da3753627d7ca3f6be METADATA
buildCacheKey=0b767ff69ffa86da3753627d7ca3f6be
buildInvocationId=okqe4uw6dzgxjkjfs4eu52wslm
creationTime=1792180150008
executionTime=11
gradleVersion=9.8.1
identity=\:tidepool-model\:compileJava
type=org.gradle.api.internal.tasks.execution.TaskExecution
The entry holds the class files and the compiler’s incremental compilation data (previousCompilationData), and nothing of the sources. The 4 MB entry is the HTML Javadoc. The modification time of the empty file gc.properties records when the cleanup (see below) last ran, and build-cache-1.lock is the lock file; the -1 in the directory name is the format version of the cache.
Cacheable tasks
A task is cacheable only if its type opted in. The built-in tasks that did, according to the user manual, are these; all other built-in tasks are not.
| Area | Cacheable tasks |
|---|---|
Java |
|
Groovy |
|
Scala |
|
Native |
|
Testing |
|
Code quality |
|
JaCoCo |
|
Other |
|
The third-party picture has changed since the early days: the Android Gradle plugin (3.1 and later) and the Kotlin Gradle plugin (1.2.21 and later) both ship cacheable compilation tasks, so the cache works for them without extra code. For any other plugin, its documentation says whether its tasks are cacheable; the --info line Caching disabled for task ... because: Caching has not been enabled for the task is how you find out.
Why Copy, Jar and Zip are not cached
Tasks such as Copy, Sync, Jar, Zip and Tar are not worth caching: they move bytes from one place on the disk to another, which is usually faster than downloading a stored copy, and caching them would waste space. The annotation @DisableCachingByDefault(because = "...") documents that on a task class (the build can still override it with the runtime API, see below), and @CacheableTask opts in. A subclass does not inherit @CacheableTask; a custom task type is not cacheable until you say so. Test tasks are cacheable, and that has a consequence that the next section comes back to: a cached test is a test that was not run in this build.
Making a custom task cacheable
The tidepool engine gets a small custom task that counts the stations in the CSV file and writes the count. It has one input file and one output file. The first version declares both and nothing else; it is a perfectly good task for incremental builds:
// tidepool-engine/build.gradle.kts
abstract class StationSummary : DefaultTask() {
@get:InputFile
abstract val stations: RegularFileProperty
@get:OutputFile
abstract val summary: RegularFileProperty
@TaskAction
fun summarize() {
val rows = stations.get().asFile.readLines().drop(1).filter { it.isNotBlank() }
summary.get().asFile.writeText("stations=${rows.size}\n")
}
}
tasks.register<StationSummary>("stationSummary") {
stations = rootProject.layout.projectDirectory.file("data/stations.csv")
summary = layout.buildDirectory.file("station-summary.txt")
}
// tidepool-engine/build.gradle
abstract class StationSummary extends DefaultTask {
@InputFile
abstract RegularFileProperty getStations()
@OutputFile
abstract RegularFileProperty getSummary()
@TaskAction
void summarize() {
def rows = stations.get().asFile.readLines().drop(1).findAll { it.trim() }
summary.get().asFile.text = "stations=${rows.size()}\n"
}
}
tasks.register('stationSummary', StationSummary) {
stations = rootProject.layout.projectDirectory.file('data/stations.csv')
summary = layout.buildDirectory.file('station-summary.txt')
}
With the cache on, --info says that the task is not cacheable, which is the default for a custom type:
> Task :tidepool-engine:stationSummary
Caching disabled for task ':tidepool-engine:stationSummary' because:
Caching has not been enabled for the task
Task ':tidepool-engine:stationSummary' is not up-to-date because:
No history is available.
Adding @CacheableTask in front of the class is not enough, and Gradle does not let it pass silently. A cacheable task must say how each file input is normalized, that is, which part of the path and the content identifies the file, because otherwise the outputs cannot be reused between machines or between locations on the same machine. Both DSLs fail the build with a validation problem (the Groovy DSL names the type StationSummary, the Kotlin DSL Build_gradle.StationSummary):
* What went wrong:
A problem was found with the configuration of task ':tidepool-engine:stationSummary' (type 'Build_gradle.StationSummary').
Missing normalization
Type 'Build_gradle.StationSummary' property 'stations' is annotated with @InputFile but missing a normalization strategy
If you don't declare the normalization, outputs can't be re-used between machines or locations on the same machine, therefore caching efficiency drops significantly
For more information, please refer to https://docs.gradle.org/9.8.1/userguide/validation_problems.html#missing_normalization_annotation in the Gradle documentation.
Possible solution: Declare the normalization strategy by annotating the property with either @PathSensitive, @Classpath or @CompileClasspath.
The normalization is declared with @PathSensitive (or @Classpath / @CompileClasspath for classpaths). The property that holds the input file gets @PathSensitive(PathSensitivity.ABSOLUTE) first, the most conservative choice, which says that the whole path of the file matters; the section on relocatability below shows what that costs and fixes it.
The runtime API for tasks you cannot subclass
When the task is of a type you do not control (an Exec, a generic task of a plugin, an ad hoc task), the same information is declared with the runtime API: outputs.cacheIf { true } opts the task in, and inputs.file(...) and outputs.file(...) with withPropertyName and withPathSensitivity declare the inputs and outputs. The declarations have the same effect as the annotations; file inputs and outputs declared in a base class cannot be overridden this way, input properties can. The example is an ad hoc task with the same behaviour as the class above; it was run in both DSLs and was cached and restored:
// tidepool-engine/build.gradle.kts
val stationCount = tasks.register("stationCount") {
val stations = rootProject.layout.projectDirectory.file("data/stations.csv")
val count = layout.buildDirectory.file("station-count.txt")
inputs.file(stations)
.withPropertyName("stations")
.withPathSensitivity(PathSensitivity.NONE)
outputs.file(count)
.withPropertyName("count")
outputs.cacheIf { true }
doLast {
val rows = stations.asFile.readLines().drop(1).filter { it.isNotBlank() }
count.get().asFile.writeText("stations=${rows.size}\n")
}
}
// tidepool-engine/build.gradle
def stationCount = tasks.register('stationCount') {
def stations = rootProject.layout.projectDirectory.file('data/stations.csv')
def count = layout.buildDirectory.file('station-count.txt')
inputs.file(stations)
.withPropertyName('stations')
.withPathSensitivity(PathSensitivity.NONE)
outputs.file(count)
.withPropertyName('count')
outputs.cacheIf { true }
doLast {
def rows = stations.asFile.readLines().drop(1).findAll { it.trim() }
count.get().asFile.text = "stations=${rows.size()}\n"
}
}
$ ./gradlew stationCount --build-cache --info
> Task :tidepool-engine:stationCount
Build cache key for task ':tidepool-engine:stationCount' is 284e3cc127aae31fac3a5ceab445e621
Task ':tidepool-engine:stationCount' is not up-to-date because:
No history is available.
Stored cache entry for task ':tidepool-engine:stationCount' with cache key 284e3cc127aae31fac3a5ceab445e621
$ ./gradlew clean stationCount --build-cache --info
> Task :tidepool-engine:stationCount FROM-CACHE
Build cache key for task ':tidepool-engine:stationCount' is 284e3cc127aae31fac3a5ceab445e621
Task ':tidepool-engine:stationCount' is not up-to-date because:
Output property 'count' file /work/tidepool/tidepool-engine/build/station-count.txt has been removed.
Loaded cache entry for task ':tidepool-engine:stationCount' with cache key 284e3cc127aae31fac3a5ceab445e621
The task ran in the first build (No history is available, the key is computed and the entry stored) and, after clean had removed its output, was restored from the cache instead of executed. Notice the line is not up-to-date because: Output property 'count' file ... has been removed: the up-to-date check failed first, which is why the cache was consulted at all (see the figure).
The inverse is outputs.doNotCacheIf("reason") { predicate }, which disables caching for a task that is normally cacheable when the predicate is true. The classic use is a Test task that must run every time in some environments (a nightly build, a flaky suite, tests that read the clock). There is a configuration cache trap in the obvious way to write it. The predicate is a lambda that runs at execution time, and providers inside it is the Project; the configuration cache then cannot store the task:
$ NIGHTLY=1 ./gradlew :tidepool-engine:test --build-cache
* What went wrong:
Configuration cache problems found in this build.
1 problem was found storing the configuration cache.
- Task `:tidepool-engine:test` of type `org.gradle.api.tasks.testing.Test`: cannot serialize object of type 'org.gradle.api.internal.project.DefaultProject', a subtype of 'org.gradle.api.Project', as these are not supported with the configuration cache.
See https://docs.gradle.org/9.8.1/userguide/configuration_cache_requirements.html#config_cache:requirements:disallowed_types
$ NIGHTLY=1 ./gradlew :tidepool-engine:test --build-cache
1 problem was found storing the configuration cache.
- Task `:tidepool-engine:test` of type `org.gradle.api.tasks.testing.Test`: invocation of 'providers' references a Gradle script object from a Groovy closure at execution time, which is unsupported with the configuration cache.
See https://docs.gradle.org/9.8.1/userguide/configuration_cache_requirements.html#config_cache:requirements:gradle_model_types
* What went wrong:
Execution failed for task ':tidepool-engine:test' (registered by plugin 'org.gradle.jvm-test-suite').
> Invocation of 'providers' references a Gradle script object from a Groovy closure at execution time, which is unsupported with the configuration cache.
The first listing is the Kotlin DSL build, the second the Groovy DSL build (the two DSLs report it differently). The fix is to create the provider at configuration time, inside the configuration block but outside the lambda, so that the lambda captures only the provider:
// tidepool-engine/build.gradle.kts
tasks.test {
val nightly = providers.environmentVariable("NIGHTLY")
outputs.doNotCacheIf("Tests are re-run on every nightly build") { nightly.isPresent }
}
// tidepool-engine/build.gradle
tasks.named('test') {
def nightly = providers.environmentVariable('NIGHTLY')
outputs.doNotCacheIf('Tests are re-run on every nightly build') { nightly.present }
}
$ NIGHTLY=1 ./gradlew :tidepool-engine:test --build-cache --info
> Task :tidepool-engine:test
Caching disabled for task ':tidepool-engine:test' because:
'Tests are re-run on every nightly build' satisfied
$ ./gradlew clean :tidepool-engine:test --build-cache --info
> Task :tidepool-engine:test
Build cache key for task ':tidepool-engine:test' is f9a68714a2f389d2c532a1ed4c2feb96
Stored cache entry for task ':tidepool-engine:test' with cache key f9a68714a2f389d2c532a1ed4c2feb96
A variable at the top level of the script would not do in the Kotlin DSL: the lambda then captures the script object, and the configuration cache reports cannot serialize Gradle script object references (this was run: it fails). Keep captured values local to the configuration block and of a serializable type, as explained on Configuration Cache & Isolated Projects.
The message printed when a predicate is true is the reason string in quotes followed by satisfied, which is why the reason should read as a short statement.
Logic in the build script is part of the key
The task implementation and the implementation of its actions are inputs, and for a task class or a doLast action defined in a build script, the "implementation" is the class loader of the whole compiled script. So an edit to an unrelated line of the script changes the key of every cacheable task that is defined in it, while the keys of tasks that Gradle itself defines (compileJava) stay as they are. The listing prints the keys of stationCount and compileJava in the engine, appends an unrelated task to the engine’s build script, and prints them again:
$ ./gradlew stationCount compileJava --build-cache --info --rerun | grep "Build cache key"
Build cache key for task ':tidepool-engine:stationCount' is 284e3cc127aae31fac3a5ceab445e621
Build cache key for task ':tidepool-engine:compileJava' is 2903f2d7209d1b749fbeffefcfbcd51c
# ... after appending an unrelated task to the build script ...
Build cache key for task ':tidepool-engine:stationCount' is b2173322ff0e3c284ebf1de8a0c0e0e4
Build cache key for task ':tidepool-engine:compileJava' is 2903f2d7209d1b749fbeffefcfbcd51c
The key of the script’s own task changed (the Groovy DSL run showed the same change, with different hashes), the compiler’s did not. For anything cacheable that you write, put the code in a plugin in build-logic or buildSrc instead of in a script: the plugin JAR changes only when the plugin does. That is the design on Convention Plugins & Sharing Build Logic.
Overlapping outputs
If two tasks write to the same file or directory, Gradle cannot say which task a file belongs to and disables caching for the task that runs second (since Gradle 4.0). The example registers a second StationSummary task with the same output file:
$ ./gradlew stationSummary stationSummaryAgain --build-cache --info
> Task :tidepool-engine:stationSummary
Build cache key for task ':tidepool-engine:stationSummary' is e438eef3c73bb6b3c171b52ce97735b1
Task ':tidepool-engine:stationSummary' is not up-to-date because:
No history is available.
--
> Task :tidepool-engine:stationSummaryAgain
Caching disabled for task ':tidepool-engine:stationSummaryAgain' because:
Gradle does not know how file 'build/station-summary.txt' was created (output property 'summary'). 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 ':tidepool-engine:stationSummaryAgain' is not up-to-date because:
In the Groovy DSL run the two tasks happened to be scheduled in the opposite order, so the task that was refused was stationSummary and the one that was stored was stationSummaryAgain; which is which depends on the scheduling. The message ends with the rule: "multiple tasks are not allowed to produce output in the same location".
Other switches
$ ./gradlew build --build-cache
BUILD SUCCESSFUL in 979ms
16 actionable tasks: 16 executed
$ ./gradlew build --build-cache --rerun-tasks
BUILD SUCCESSFUL in 879ms
16 actionable tasks: 16 executed
$ ./gradlew clean build --no-build-cache
BUILD SUCCESSFUL in 794ms
20 actionable tasks: 20 executed
$ ./gradlew clean build --build-cache
BUILD SUCCESSFUL in 361ms
20 actionable tasks: 12 executed, 8 from cache
--rerun-tasks re-executes every task and does not load from the cache, but it still stores the outputs (--info printed Stored cache entry for the task in that run). --no-build-cache ignores the cache in both directions. A task run with --rerun (the task option) also skips the lookup. All three are for debugging, not for normal builds.
The local build cache
The local cache is a DirectoryBuildCache, enabled by default once the build cache is on, with push enabled. Its entries are in $GRADLE_USER_HOME/caches/build-cache-1 (see Directories & Logging for the whole layout of the Gradle User Home). It is configured in settings.gradle(.kts), the only place for build cache configuration; the three properties are in the table.
| Property (Kotlin DSL / Groovy DSL) | Default | Meaning |
|---|---|---|
|
|
Where the entries are stored. |
|
|
Whether the build reads from this cache. |
|
|
Whether the build writes outputs into this cache. |
In the Kotlin DSL the Boolean properties are named isEnabled and isPush (the Groovy DSL spells them without the prefix). The user manual’s local-cache snippet writes enabled and push in the Kotlin DSL too; push was tried here and does not compile in 9.8.1 (Unresolved reference 'push'), and the Develocity documentation uses the is form. The example moves the cache into the project and makes it read-only, which is what a build agent that must not feed a shared cache would do:
// settings.gradle.kts
buildCache {
local {
directory = File(rootDir, "build-cache")
isPush = false
}
}
// settings.gradle
buildCache {
local {
directory = new File(rootDir, 'build-cache')
push = false
}
}
$ ./gradlew build --build-cache --info (push = false)
Using local directory build cache for the root build (pull-only, location = /work/tidepool/build-cache, remove unused entries = after 7 days).
BUILD SUCCESSFUL in 860ms
16 actionable tasks: 16 executed
$ ls build-cache
build-cache.lock
gc.properties
The --info line says pull-only when push is off, and nothing was stored: the directory holds only its lock and gc.properties. With isPush = true (the default) the same build stores its entries in the new directory (the lines about the cache location are printed twice in --info output; the listing shows them once):
$ ./gradlew clean build --build-cache --info (push = true)
Using local directory build cache for the root build (location = /work/tidepool/build-cache, remove unused entries = after 7 days).
BUILD SUCCESSFUL in 1s
20 actionable tasks: 20 executed
$ ls build-cache
0b767ff69ffa86da3753627d7ca3f6be
2903f2d7209d1b749fbeffefcfbcd51c
2a2be1f7c7e47566e5f52402aded39b1
540501a12ca9522f09ca57a10e26e71b
8e5fdbed08e75ead5be1704197c1177f
96768bcfbe3e490e2bed5c12966bccc1
9daed805ee409c990c425e4e78e26d5c
build-cache.lock
f9a68714a2f389d2c532a1ed4c2feb96
gc.properties
The local cache pays off for a single developer too: it is what makes switching branches back and forth, or running git bisect, cheap, because the outputs of an earlier state of the sources are still there. It is also useful on CI as a mirror of the remote cache, which cuts the network traffic.
Cleanup
Entries that were not used for some time are removed, so the directory does not grow without bound. Since Gradle 8.8 the retention period and the switch are set like for the other caches of the Gradle User Home, in an init script in $GRADLE_USER_HOME/init.d; the default is 7 days since the last use (the --info line above says remove unused entries = after 7 days). Cleanup.DEFAULT runs the cleanup periodically in the background, Cleanup.DISABLED never, and Cleanup.ALWAYS at the end of every build in the foreground:
// $GRADLE_USER_HOME/init.d/build-cache-cleanup.init.gradle.kts
beforeSettings {
caches {
buildCache.setRemoveUnusedEntriesAfterDays(1)
cleanup = Cleanup.ALWAYS
}
}
// $GRADLE_USER_HOME/init.d/build-cache-cleanup.init.gradle
beforeSettings { settings ->
settings.caches {
buildCache.removeUnusedEntriesAfterDays = 1
cleanup = Cleanup.ALWAYS
}
}
$ ./gradlew help --build-cache --info
Using local directory build cache for the root build (location = $GRADLE_USER_HOME/caches/build-cache-1, remove unused entries = after 24h).
Build cache ($GRADLE_USER_HOME/caches/build-cache-1) removing files not accessed on or after Thu Oct 15 21:49:31 CEST 2026.
Build cache ($GRADLE_USER_HOME/caches/build-cache-1) cleanup deleted 0 files/directories.
Build cache ($GRADLE_USER_HOME/caches/build-cache-1) cleaned up in 0.0 secs.
Using local directory build cache for the root build (location = $GRADLE_USER_HOME/caches/build-cache-1, remove unused entries = after 24h).
Build cache ($GRADLE_USER_HOME/caches/build-cache-1) removing files not accessed on or after Thu Oct 15 21:49:31 CEST 2026.
Build cache ($GRADLE_USER_HOME/caches/build-cache-1) cleanup deleted 0 files/directories.
Build cache ($GRADLE_USER_HOME/caches/build-cache-1) cleaned up in 0.0 secs.
Gradle printed the whole block twice in this run; the listing shows both. That the cleanup ran, with the new period of 24 hours, and the cutoff time it applied are real output; that it deleted nothing is expected, because the entries were seconds old. The effect of a period (an entry unused for longer than the period disappearing) needs time to pass and was not observed here (not run here). Gradle 8.8 to 8.14 have the same mechanism (not run here); before 8.8 the retention was set with buildCache { local { removeUnusedEntriesAfterDays = n } } in the settings file and the cleanup ran every 24 hours without a switch; that property was removed in 9.0.0. The generic cleanup settings of the Gradle User Home are on Directories & Logging.
A remote HTTP build cache
A remote cache lets one machine reuse the work of another. Gradle ships a client for it, HttpBuildCache, and the protocol is almost nothing: a GET of <url><key> that answers 200 with the entry, or 404 when there is none; a PUT of the same URL that stores an entry (any 2xx is success, a 413 means "too big" and is not an error). Any HTTP server that does this can be a cache node. When both caches are enabled, Gradle looks in the local cache first, then in the remote one, and copies a remote hit into the local cache; it pushes to every enabled cache whose push is true. The local cache pushes by default, a remote one does not.
| Property (Kotlin DSL / Groovy DSL) | Default | Meaning |
|---|---|---|
|
none |
Base URL of the node; the key is appended to it. Include the trailing slash. |
|
|
Write outputs to the node. |
|
|
Read from the node. |
|
|
Allow an |
|
|
Disable the check of the server certificate (a security risk, a temporary measure). |
|
|
Ask the server whether it will accept the body before uploading it. |
|
none |
HTTP Basic authentication, sent preemptively. |
The recommended topology: CI pushes, developers pull
The official recommendation, and the one every serious setup converges to, is that a continuous integration server populates the cache from clean builds, while developers only load from it. A developer can change the inputs or the outputs of a task while it is executing, for instance by saving a file in the IDE while the build runs; Gradle has no defence against that and would cache whatever is in the output directory when the task finishes, which can poison the cache for everybody. A CI agent builds a pristine checkout and does not suffer from it. In the settings file that is one line:
// settings.gradle.kts
val isCi = providers.environmentVariable("CI").isPresent
buildCache {
remote<HttpBuildCache> {
url = uri("http://127.0.0.1:5071/cache/")
isAllowInsecureProtocol = true
isPush = isCi
}
}
// settings.gradle
boolean isCi = providers.environmentVariable('CI').present
buildCache {
remote(HttpBuildCache) {
url = 'http://127.0.0.1:5071/cache/'
allowInsecureProtocol = true
push = isCi
}
}
The URL points at a plain-HTTP node on the local machine because that is the only server these examples had; a real node uses an https:// URL, and then allowInsecureProtocol is not set. The property is required for plain HTTP. The runs of this page did not show it because Gradle exempts loopback addresses from the check: with the node at http://127.0.0.1 Gradle 9.8.1 accepted the URL with or without the property, also under --warning-mode all. A non-loopback http:// URL without allowInsecureProtocol = true stops the build while the settings are evaluated (reproduced with the documentation address http://192.0.2.1:5071/cache/):
FAILURE: Build failed with an exception.
* What went wrong:
Using insecure protocols with remote build cache, without explicit opt-in, is unsupported.
* Try:
> Switch remote build cache to a secure protocol (like HTTPS) or allow insecure protocols.
…
BUILD FAILED in 400ms
A toy cache node
To show the protocol and the topology without installing anything, the examples use this node: a PUT writes a file named by the key, a GET reads it, anything else is 404. It can require HTTP Basic authentication (--user, the password is read from an environment variable and never printed) and it logs one line per request. It has no eviction, no TLS and no limits, so it is a teaching tool, not a cache for a team; for that see the next sections.
#!/usr/bin/env python3
"""A toy Gradle HTTP build cache node: GET and PUT of opaque entries, one file per cache key.
Not for production use: no eviction, no TLS and (unless --user is given) no authentication.
"""
import argparse
import base64
import hmac
import os
import sys
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=5071)
parser.add_argument("--dir", default="entries")
parser.add_argument("--user")
parser.add_argument("--password-env", default="CACHE_PASSWORD")
args = parser.parse_args()
os.makedirs(args.dir, exist_ok=True)
EXPECTED = None
if args.user:
token = f"{args.user}:{os.environ[args.password_env]}".encode()
EXPECTED = "Basic " + base64.b64encode(token).decode()
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *a):
pass
def _key(self):
key = self.path.rsplit("/", 1)[-1]
return key if key.isalnum() else None
def _authorized(self):
if EXPECTED is None:
return True
return hmac.compare_digest(self.headers.get("Authorization", ""), EXPECTED)
def _log(self, status, size):
key = self.path.rsplit("/", 1)[-1]
print(f"{self.command} {key} {status.value} {size}", flush=True)
def _reply(self, status, body=b"", logged=None):
self.send_response(status)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
self._log(status, len(body) if logged is None else logged)
def do_GET(self):
key = self._key()
if not self._authorized():
return self._reply(HTTPStatus.UNAUTHORIZED)
path = os.path.join(args.dir, key or "")
if key and os.path.isfile(path):
with open(path, "rb") as f:
return self._reply(HTTPStatus.OK, f.read())
self._reply(HTTPStatus.NOT_FOUND)
def do_PUT(self):
key = self._key()
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length)
if not self._authorized():
return self._reply(HTTPStatus.UNAUTHORIZED)
with open(os.path.join(args.dir, key), "wb") as f:
f.write(body)
self._reply(HTTPStatus.CREATED, logged=len(body))
ThreadingHTTPServer(("127.0.0.1", args.port), Handler).serve_forever()
Starting it is python3 -I cache_node.py --dir entries --port 5071.
The script is this page’s own setup, not an example taken from the manual; the protocol it implements is the one described in the manual.
CI pushes, a developer pulls
The first run is the CI one: an empty Gradle User Home, CI set, so the build pushes. The listing shows the build’s own summary, then what the node saw: one GET (a miss) and one PUT per cache entry, 15 of each for the Kotlin DSL (8 task entries and 7 further entries for the compilation of the Kotlin DSL build scripts; --info names them Build cache key for Kotlin DSL script compilation, and in these runs they appeared only when a remote cache was configured) and 8 of each for the Groovy DSL (Groovy script compilation is not cached since 8.8):
$ CI=true ./gradlew clean build --build-cache
BUILD SUCCESSFUL in 10s
20 actionable tasks: 16 executed, 4 up-to-date
# requests seen by the cache node
GET 404 x 15
PUT 201 x 15
# first requests
GET 0e9d664de825f1e11409e839dadfe1ad 404 0
PUT 0e9d664de825f1e11409e839dadfe1ad 201 2811
GET 6c995bf76502c3896a937ee6057fff0b 404 0
PUT 6c995bf76502c3896a937ee6057fff0b 201 3507
The developer works in a second checkout in another directory, with an empty Gradle User Home and without CI, so the build does not push. Every cacheable task is served by the node (the GET 200 count includes the script compilations), and the paths are different from the CI ones, which is relocatability at work:
$ ./gradlew build --build-cache # /work/tidepool-b, empty Gradle user home, no CI variable
> Task :tidepool-model:compileJava FROM-CACHE
> Task :tidepool-engine:compileJava FROM-CACHE
> Task :tidepool-model:compileTestJava FROM-CACHE
> Task :tidepool-engine:compileTestJava FROM-CACHE
> Task :tidepool-model:javadoc FROM-CACHE
> Task :tidepool-cli:compileJava FROM-CACHE
> Task :tidepool-model:test FROM-CACHE
> Task :tidepool-engine:test FROM-CACHE
BUILD SUCCESSFUL in 8s
16 actionable tasks: 8 executed, 8 from cache
# requests seen by the cache node
GET 200 x 15
The second build of the developer needs the node no more: the entries were copied into the local cache, so the node saw no request at all:
$ ./gradlew clean build --build-cache # second build, same Gradle user home
> Task :tidepool-model:compileJava FROM-CACHE
> Task :tidepool-engine:compileJava FROM-CACHE
> Task :tidepool-model:compileTestJava FROM-CACHE
> Task :tidepool-engine:compileTestJava FROM-CACHE
> Task :tidepool-cli:compileJava FROM-CACHE
> Task :tidepool-model:test FROM-CACHE
> Task :tidepool-engine:test FROM-CACHE
> Task :tidepool-model:javadoc FROM-CACHE
BUILD SUCCESSFUL in 481ms
20 actionable tasks: 12 executed, 8 from cache
# requests seen by the cache node
When the developer changes a source file (here a comment appended to Height.java), the key of that file’s compilation changes and nothing in the cache matches. The task runs; the dependent tasks, whose inputs did not change in any way that matters, stay up to date; and the node saw two lookups (404) and no PUT, because the developer’s build does not push:
$ ./gradlew build --build-cache # after appending a comment to Height.java
> Task :tidepool-model:compileJava
> Task :tidepool-engine:compileJava UP-TO-DATE
> Task :tidepool-cli:compileJava UP-TO-DATE
> Task :tidepool-model:test UP-TO-DATE
> Task :tidepool-engine:test UP-TO-DATE
> Task :tidepool-cli:test NO-SOURCE
BUILD SUCCESSFUL in 788ms
16 actionable tasks: 3 executed, 13 up-to-date
# requests seen by the cache node
GET 404 x 2
Credentials and what goes wrong
A node that requires authentication answers 401. A build without credentials does not fail; it reports the problem and carries on without the remote cache, which is the general rule for cache errors (a cache is an optimization, never a precondition). The listing is the Kotlin DSL build, where the first failure is the compilation of the Kotlin DSL script, which happens at configuration time; the Groovy DSL build printed only the second one:
$ ./gradlew clean build --build-cache # node requires Basic authentication, build has no credentials
> Configure project :
Could not load entry 0e9d664de825f1e11409e839dadfe1ad from remote build cache: Loading entry from 'http://127.0.0.1:5071/cache/0e9d664de825f1e11409e839dadfe1ad' response status 401: Unauthorized
The remote build cache was disabled during the build due to errors.
Could not load entry 0b767ff69ffa86da3753627d7ca3f6be from remote build cache: Loading entry from 'http://127.0.0.1:5071/cache/0b767ff69ffa86da3753627d7ca3f6be' response status 401: Unauthorized
BUILD SUCCESSFUL in 9s
20 actionable tasks: 16 executed, 4 up-to-date
The remote build cache was disabled during the build due to errors.
# requests seen by the cache node
GET 401 x 2
Credentials are declared in the node’s block. Read the password from the environment (or from a gradle.properties outside version control), never from a literal:
// settings.gradle.kts
buildCache {
remote<HttpBuildCache> {
url = uri("http://127.0.0.1:5071/cache/")
isPush = isCi
credentials {
username = "ci"
password = providers.environmentVariable("CACHE_PASSWORD").get()
}
}
}
// settings.gradle
buildCache {
remote(HttpBuildCache) {
url = 'http://127.0.0.1:5071/cache/'
push = isCi
credentials {
username = 'ci'
password = providers.environmentVariable('CACHE_PASSWORD').get()
}
}
}
With the credentials in place the CI build pushes again (the password in this run was a random throw-away value that was never printed):
$ CI=true ./gradlew clean build --build-cache # with credentials
BUILD SUCCESSFUL in 10s
20 actionable tasks: 20 executed
# requests seen by the cache node
GET 404 x 15
PUT 201 x 15
--info shows the configuration that Gradle ended up with, including whether it is authenticated and whether it is pull-only:
$ ./gradlew help --build-cache --info | grep "remote HTTP"
Using remote HTTP build cache for the root build (pull-only, allowInsecureProtocol = true, allowUntrustedServer = false, authenticated = true, url = http://127.0.0.1:5071/cache/, useExpectContinue = false).
If the node is not running at all, the result is the same as for the 401: a warning and a build that proceeds with the work it would have done anyway.
$ ./gradlew clean build --build-cache # the cache node is not running
Could not load entry 0e9d664de825f1e11409e839dadfe1ad from remote build cache: Connect to 127.0.0.1:5071 [/127.0.0.1] failed: Connection refused
The remote build cache was disabled during the build due to errors.
Could not load entry 0b767ff69ffa86da3753627d7ca3f6be from remote build cache: Connect to 127.0.0.1:5071 [/127.0.0.1] failed: Connection refused
BUILD SUCCESSFUL in 9s
20 actionable tasks: 20 executed
The remote build cache was disabled during the build due to errors.
Gradle retries requests that fail after the connection was established up to three times; if the problem persists, the cache operation fails and the remote cache is disabled for the rest of the build (as in the listings above). --offline disables the remote cache without touching the node:
$ ./gradlew clean build --build-cache --offline # local build cache emptied first
Remote build cache is disabled when running with --offline.
BUILD SUCCESSFUL in 1s
20 actionable tasks: 20 executed
# requests seen by the cache node: 0
Redirects (3xx) are followed; only 307 and 308 are followed for a PUT, the others are followed with a GET without the body. An HTTPS node needs a certificate that the build JVM trusts: add the CA to the trust store, or point the JVM at another one; allowUntrustedServer is the last resort. useExpectContinue helps when the node often rejects or redirects uploads (a 413 for an entry that is too large, for example), because the body is then not sent in vain; not every server and proxy implements it correctly.
Choosing a node
-
Develocity (commercial). The vendor of Gradle sells Develocity, whose build cache node is the high-performance, centrally managed backend, with Build Scan comparison to find out why two builds got different keys. Its Gradle plugin (
com.gradle.develocity, 4.6.0) providesdevelocity.buildCacheas the type for theremoteblock; the snippet in the Develocity documentation readsremote(develocity.buildCache) { server = "https://develocity.example.com"; path = "/cache/"; isPush = true }after disabling the local cache withisEnabled = false. Not run here (the service and the plugin’s terms of use are out of scope for this page); the other uses of the plugin are on Performance Profiling & Build Scans. -
Gradle’s Docker build cache node. A Docker image of a cache node exists; it can run on its own, with restricted functionality, or connect to Develocity for management. Not run here.
-
Any HTTP server that implements the
GET/PUTprotocol above (the user manual shows nothing else), for instance behind a reverse proxy with authentication and TLS. The responsibility for capacity and eviction is yours. -
Your own backend. A custom
BuildCacheServicecan be registered withBuildCacheConfiguration.registerBuildCacheServicefor stores that are not HTTP.
Security of a shared cache
Whoever can write to the cache can make every other build run what they wrote: a cache hit is an executable result (a JAR, a class file) that never went through the compiler of the machine that uses it. So the push right deserves the same care as the right to publish an artifact. Push only from builds that run on trusted, clean, ephemeral agents from reviewed code (not from pull requests of forks); give the push credentials only to those, and give developers read-only access; use HTTPS; never use allowUntrustedServer in a build that is not a throw-away. The wider picture is on Supply-Chain Security for JVM Builds and CI and IDE Integration.
Composite builds and buildSrc
The build cache configuration of the root build applies to the whole composite: included builds, and buildSrc of the root and of the included builds, inherit it and their own settings are ignored (since 4.5 and 5.0). Plugin builds included with pluginManagement { includeBuild(...) } are the exception, because they are loaded before the cache is configured. See Gradle Composite Builds.
Source: Gradle User Manual: Remote HTTP build cache, Specifying access credentials, Network error handling, Configuration use cases, Share results between CI builds, Share results between developers, How to set up an HTTP build cache backend, Build cache, composite builds and buildSrc, DSL: HttpBuildCache, Develocity Gradle plugin: Build Cache
Relocatability and path sensitivity
A task is relocatable if its outputs can be reused when the same task runs somewhere else: in another directory, another user’s home, another operating system. Whether it is depends on which paths the key contains. Every file input has a path sensitivity, declared with @PathSensitive:
PathSensitivity |
The key contains |
|---|---|
|
The full path of every file and directory. Not relocatable; this is also what you get for an input without declaration, and Gradle now insists that a cacheable task declare something. |
|
The location relative to the root of the file collection (the source directory, for example). Relocatable. |
|
Only the file name. Relocatable and tolerant of moved files. |
|
No path at all, only the content. Relocatable and tolerant of renames. |
The right value is the least sensitive one that is still correct: a task whose result depends on the file names (a compiler that derives class names from them) needs NAME_ONLY or RELATIVE; one that only reads the content, like StationSummary, needs NONE. The Java compiler’s sources are NAME_ONLY/RELATIVE, as the documentation explains: only the relative path matters for incremental compilation, not where the project lives.
The effect is easy to demonstrate with the two-checkout test from the official debugging guide. StationSummary first declares ABSOLUTE:
abstract class StationSummary : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.ABSOLUTE)
abstract val stations: RegularFileProperty
// ... as before
abstract class StationSummary extends DefaultTask {
@InputFile
@PathSensitive(PathSensitivity.ABSOLUTE)
abstract RegularFileProperty getStations()
// ... as before
The task is run with -Dorg.gradle.caching.debug=true in /work/tidepool and then, with the same build files and the same Gradle User Home, in a copy in /work/tidepool-b. The line that matters is the one for the input file: the fingerprint hash, and the path in it, differ, so the key differs and the second checkout executes the task:
$ ./gradlew stationSummary --build-cache -Dorg.gradle.caching.debug=true # in /work/tidepool
> Task :tidepool-engine:stationSummary
Appending input file fingerprints for 'stations' to build cache key: 7e12aed9badc1e23946dab50eaec6e5d - ABSOLUTE_PATH{/work/tidepool/data/stations.csv='/work/tidepool/data/stations.csv' / d96a0a4ce7b357ba41ed05c80d875c1b}
Build cache key for task ':tidepool-engine:stationSummary' is 0d95a3de8687b04a82f22770e075e947
$ ./gradlew stationSummary --build-cache -Dorg.gradle.caching.debug=true # in /work/tidepool-b
> Task :tidepool-engine:stationSummary
Appending input file fingerprints for 'stations' to build cache key: 9cac396cbac7022978c1bdd0ffdf5e25 - ABSOLUTE_PATH{/work/tidepool-b/data/stations.csv='/work/tidepool-b/data/stations.csv' / d96a0a4ce7b357ba41ed05c80d875c1b}
Build cache key for task ':tidepool-engine:stationSummary' is 48eb5e49ebb03d87e8d623e6d5a4c0b3
With NONE, the paths are marked IGNORED_PATH; the content hash (the number after the slash, d96a0a4c..., which is the same as before) is all that counts, the key is the same in both checkouts, and the second one is served from the cache:
$ ./gradlew stationSummary --build-cache -Dorg.gradle.caching.debug=true # in /work/tidepool
> Task :tidepool-engine:stationSummary
Appending input file fingerprints for 'stations' to build cache key: 694cac4a05ebaa7052aa275a0995389c - IGNORED_PATH{/work/tidepool/data/stations.csv=IGNORED / d96a0a4ce7b357ba41ed05c80d875c1b}
Build cache key for task ':tidepool-engine:stationSummary' is cd4a55525643020422d1ae967aad26f1
BUILD SUCCESSFUL in 264ms
1 actionable task: 1 executed
$ ./gradlew stationSummary --build-cache -Dorg.gradle.caching.debug=true # in /work/tidepool-b
> Task :tidepool-engine:stationSummary FROM-CACHE
Appending input file fingerprints for 'stations' to build cache key: 694cac4a05ebaa7052aa275a0995389c - IGNORED_PATH{/work/tidepool-b/data/stations.csv=IGNORED / d96a0a4ce7b357ba41ed05c80d875c1b}
Build cache key for task ':tidepool-engine:stationSummary' is cd4a55525643020422d1ae967aad26f1
BUILD SUCCESSFUL in 250ms
1 actionable task: 1 from cache
The same test applies to a whole build, and is the one the official guide recommends before a remote cache is introduced: build in one checkout, clean the second, build there with the same local cache, and expect FROM-CACHE for every cacheable task. With the tidepool build:
> Task :tidepool-model:compileJava FROM-CACHE
> Task :tidepool-model:compileTestJava FROM-CACHE
> Task :tidepool-engine:compileJava FROM-CACHE
> Task :tidepool-model:test FROM-CACHE
> Task :tidepool-engine:compileTestJava FROM-CACHE
> Task :tidepool-cli:compileJava FROM-CACHE
> Task :tidepool-engine:test FROM-CACHE
> Task :tidepool-model:javadoc FROM-CACHE
BUILD SUCCESSFUL in 474ms
16 actionable tasks: 8 executed, 8 from cache
The 8 cacheable tasks of the build were restored in a checkout in another directory, which proves that the compiler, test and Javadoc tasks are relocatable and that the declarations in the build are good enough for sharing. The test is cheap and catches the most common mistake (an absolute path in an input) at once; it should be in the CI of any build that wants a remote cache.
Stable inputs and input normalization
A cacheable task needs repeatable outputs (running it twice on the same inputs produces the same files, otherwise what is stored and what is executed differ) and stable inputs (the same logical inputs produce the same key). The usual enemies of stable inputs are in the manual’s list: a version number with a time stamp in a manifest, a commit hash written into a properties file, the output of another task that writes the time. A task consuming such data gets a new key on every build and is never served from the cache.
A volatile file on the runtime classpath
To see the effect, the model project gets a task that writes build-info.txt with the version and the time of the build into a generated resources directory that is added to the main source set’s output. It is how many builds add a "built at" stamp:
// tidepool-model/build.gradle.kts
import java.time.Instant
abstract class BuildInfo : DefaultTask() {
@get:Input
abstract val version: Property<String>
@get:OutputFile
abstract val propertiesFile: RegularFileProperty
@TaskAction
fun write() {
propertiesFile.get().asFile.writeText("version=${version.get()}\nbuilt-at=${Instant.now()}\n")
}
}
val buildInfo = tasks.register<BuildInfo>("buildInfo") {
version = project.version.toString()
propertiesFile = layout.buildDirectory.file("generated-resources/build-info.txt")
}
sourceSets.main {
output.dir(buildInfo.map { it.propertiesFile.get().asFile.parentFile })
}
// tidepool-model/build.gradle
abstract class BuildInfo extends DefaultTask {
@Input
abstract Property<String> getVersion()
@OutputFile
abstract RegularFileProperty getPropertiesFile()
@TaskAction
void write() {
propertiesFile.get().asFile.text = "version=${version.get()}\nbuilt-at=${java.time.Instant.now()}\n"
}
}
def buildInfo = tasks.register('buildInfo', BuildInfo) {
version = project.version.toString()
propertiesFile = layout.buildDirectory.file('generated-resources/build-info.txt')
}
sourceSets.main {
output.dir(buildInfo.map { it.propertiesFile.get().asFile.parentFile })
}
Two clean builds in a row. The first has an empty cache; in the second the compilations are served from the cache but the test and javadoc tasks, which should have been, are executed:
$ ./gradlew clean build --build-cache # first build, empty cache
> Task :tidepool-model:buildInfo
> Task :tidepool-model:testClasses
> Task :tidepool-engine:testClasses
> Task :tidepool-cli:testClasses UP-TO-DATE
> Task :tidepool-cli:test NO-SOURCE
> Task :tidepool-model:test
> Task :tidepool-engine:test
> Task :tidepool-model:javadoc
> Task :tidepool-model:javadocJar
BUILD SUCCESSFUL in 975ms
21 actionable tasks: 17 executed, 4 up-to-date
$ ./gradlew clean build --build-cache # second build
> Task :tidepool-model:buildInfo
> Task :tidepool-model:compileJava FROM-CACHE
> Task :tidepool-model:compileTestJava FROM-CACHE
> Task :tidepool-model:testClasses UP-TO-DATE
> Task :tidepool-engine:compileJava FROM-CACHE
> Task :tidepool-engine:compileTestJava FROM-CACHE
> Task :tidepool-engine:testClasses UP-TO-DATE
> Task :tidepool-cli:compileJava FROM-CACHE
> Task :tidepool-cli:testClasses UP-TO-DATE
> Task :tidepool-cli:test NO-SOURCE
> Task :tidepool-model:test
> Task :tidepool-engine:test
> Task :tidepool-model:javadoc
> Task :tidepool-model:javadocJar
BUILD SUCCESSFUL in 1s
21 actionable tasks: 16 executed, 5 from cache
This is the cascade that the official debugging guide warns about. The compilations are not affected, because compile avoidance ignores resources on the compile classpath. But the model JAR now contains a different build-info.txt every time, the JAR is on the runtime classpath of the tests of the model and of the engine, and the main output is on the classpath of Javadoc; so all three tasks have a new key in every build. The place to look is the first cacheable task that was executed unexpectedly and its inputs, with the debug option. The listing shows the stableClasspath fingerprint of :tidepool-engine:test in two runs, reduced to the model JAR (the full line has one entry per classpath element); the hash of the JAR differs, and the file inside shows why:
$ ./gradlew clean :tidepool-engine:test --build-cache -Dorg.gradle.caching.debug=true # run 1
> Task :tidepool-engine:test
Appending input file fingerprints for 'stableClasspath' to build cache key: 1fa43c169db12cc13011a71cd9add109 - CLASSPATH{... /work/tidepool/tidepool-model/build/libs/tidepool-model-1.0.0.jar=IGNORED / 13bb3474d0156c29e7a8921d5a634c9d, ...}
Build cache key for task ':tidepool-engine:test' is 4bfc92c237aa20abb512b0f28da38937
$ unzip -p tidepool-model/build/libs/tidepool-model-1.0.0.jar build-info.txt
version=1.0.0
built-at=2026-10-16T19:49:33.867034Z
$ ./gradlew clean :tidepool-engine:test --build-cache -Dorg.gradle.caching.debug=true # run 2
> Task :tidepool-engine:test
Appending input file fingerprints for 'stableClasspath' to build cache key: 717b03b05a18072f41471619314189f6 - CLASSPATH{... /work/tidepool/tidepool-model/build/libs/tidepool-model-1.0.0.jar=IGNORED / e6bacc1be747f6e163a56cc789267f81, ...}
Build cache key for task ':tidepool-engine:test' is f138e3fb455a7c9d0b9dd74e49bed9a7
$ unzip -p tidepool-model/build/libs/tidepool-model-1.0.0.jar build-info.txt
version=1.0.0
built-at=2026-10-16T19:49:34.428321Z
Ignore it on the runtime classpath
The file is irrelevant for what the tests check. Gradle lets the consumer of the classpath say so with input normalization: normalization { runtimeClasspath { ignore("build-info.txt") } } removes the file from the fingerprint of every runtime classpath input of the project, for up-to-date checks and for the build cache alike. It does not change what the tests see; the file is still on the classpath. In a multi-project build the declaration is made where the tasks are, here for all projects from the root:
// build.gradle.kts (root)
allprojects {
normalization {
runtimeClasspath {
ignore("build-info.txt")
}
}
}
// build.gradle (root)
allprojects {
normalization {
runtimeClasspath {
ignore 'build-info.txt'
}
}
}
With it in place the first build misses (the key changed because the normalization did), and from the second on the tests and the Javadoc are restored although the file differs in every build:
$ ./gradlew clean build --build-cache # first build with the normalization
> Task :tidepool-model:buildInfo
> Task :tidepool-model:compileJava FROM-CACHE
> Task :tidepool-model:compileTestJava FROM-CACHE
> Task :tidepool-model:testClasses UP-TO-DATE
> Task :tidepool-engine:compileJava FROM-CACHE
> Task :tidepool-engine:compileTestJava FROM-CACHE
> Task :tidepool-engine:testClasses UP-TO-DATE
> Task :tidepool-cli:compileJava FROM-CACHE
> Task :tidepool-cli:testClasses UP-TO-DATE
> Task :tidepool-cli:test NO-SOURCE
> Task :tidepool-model:test
> Task :tidepool-engine:test
> Task :tidepool-model:javadoc
> Task :tidepool-model:javadocJar
BUILD SUCCESSFUL in 922ms
21 actionable tasks: 14 executed, 5 from cache, 2 up-to-date
$ ./gradlew clean build --build-cache # second build
> Task :tidepool-model:buildInfo
> Task :tidepool-model:compileJava FROM-CACHE
> Task :tidepool-model:compileTestJava FROM-CACHE
> Task :tidepool-model:testClasses UP-TO-DATE
> Task :tidepool-engine:compileJava FROM-CACHE
> Task :tidepool-engine:compileTestJava FROM-CACHE
> Task :tidepool-engine:testClasses UP-TO-DATE
> Task :tidepool-model:test FROM-CACHE
> Task :tidepool-cli:compileJava FROM-CACHE
> Task :tidepool-cli:testClasses UP-TO-DATE
> Task :tidepool-cli:test NO-SOURCE
> Task :tidepool-engine:test FROM-CACHE
> Task :tidepool-model:javadoc FROM-CACHE
> Task :tidepool-model:javadocJar
BUILD SUCCESSFUL in 378ms
21 actionable tasks: 13 executed, 8 from cache
A properties file is a special case, and the official example is outdated
The cache debugging guide of the user manual explains the same cascade with a Properties.store call, arguing that the time stamp that method writes changes the runtime classpath on every build. With Gradle 9.8.1 that example no longer misses, because properties files on a runtime classpath are normalized by default: Gradle loads them and compares the keys and values, and ignores comments, whitespace and the order. Replacing the task body with Properties.store (a file build-info.properties that holds version and the time stamp comment #Fri Oct 16 ...) gave a second build in which the tests and the Javadoc were restored from the cache:
$ ./gradlew clean build --build-cache # first build
> Task :tidepool-model:buildInfo
> Task :tidepool-model:testClasses
> Task :tidepool-engine:testClasses
> Task :tidepool-cli:testClasses UP-TO-DATE
> Task :tidepool-cli:test NO-SOURCE
> Task :tidepool-model:test
> Task :tidepool-engine:test
BUILD SUCCESSFUL in 1s
21 actionable tasks: 17 executed, 4 up-to-date
$ ./gradlew clean build --build-cache # second build
> Task :tidepool-model:buildInfo
> Task :tidepool-model:compileJava FROM-CACHE
> Task :tidepool-model:compileTestJava FROM-CACHE
> Task :tidepool-engine:compileJava FROM-CACHE
> Task :tidepool-model:testClasses UP-TO-DATE
> Task :tidepool-engine:compileTestJava FROM-CACHE
> Task :tidepool-engine:testClasses UP-TO-DATE
> Task :tidepool-cli:compileJava FROM-CACHE
> Task :tidepool-cli:testClasses UP-TO-DATE
> Task :tidepool-cli:test NO-SOURCE
> Task :tidepool-model:test FROM-CACHE
> Task :tidepool-engine:test FROM-CACHE
> Task :tidepool-model:javadoc FROM-CACHE
BUILD SUCCESSFUL in 348ms
21 actionable tasks: 13 executed, 8 from cache
$ cat tidepool-model/build/generated-resources/build-info.properties
#Fri Oct 16 21:52:25 CEST 2026
version=1.0.0
The time stamp is in a comment line, and comments do not count. If the stamp were a value (built-at=...) it would count, which is why the experiment above uses a plain text file; a plain text file, a manifest attribute and a class file are all compared byte for byte. Manifests and the properties under META-INF have their own normalization, with ignoreAttribute, ignoreProperty and friends; all of it is described on Gradle Incremental Build.
The JDK is an input
The Java compiler, and the Java version that runs the tests, change the output, so they are part of the key. compileJava with a toolchain for JDK 21 does not reuse the entry that a JDK 26 compilation stored, and vice versa. The model project selects the JDK from a property (the toolchain mechanism is on Gradle Toolchains):
// tidepool-model/build.gradle.kts
java {
toolchain {
languageVersion = providers.gradleProperty("jdk")
.map { JavaLanguageVersion.of(it.toInt()) }
.orElse(JavaLanguageVersion.of(26))
}
}
// tidepool-model/build.gradle
java {
toolchain {
languageVersion = providers.gradleProperty('jdk')
.map { JavaLanguageVersion.of(it as int) }
.orElse(JavaLanguageVersion.of(26))
}
}
$ ./gradlew clean compileJava --build-cache -Pjdk=26
> Task :tidepool-model:compileJava
BUILD SUCCESSFUL in 295ms
5 actionable tasks: 1 executed, 4 up-to-date
$ ./gradlew clean compileJava --build-cache -Pjdk=21
> Task :tidepool-model:compileJava
BUILD SUCCESSFUL in 277ms
5 actionable tasks: 3 executed, 2 up-to-date
$ ./gradlew clean compileJava --build-cache -Pjdk=26
> Task :tidepool-model:compileJava FROM-CACHE
BUILD SUCCESSFUL in 240ms
5 actionable tasks: 2 executed, 1 from cache, 2 up-to-date
The first build with JDK 26 stored the entry, the one with JDK 21 executed (a miss), the third with JDK 26 again was a hit. The debug output shows the three fingerprints that differ, and the two keys; everything else in the key is the same:
$ ./gradlew clean compileJava --build-cache -Dorg.gradle.caching.debug=true -Pjdk=26
Appending input value fingerprint for 'javaCompiler.metadata.languageVersion' to build cache key: bcff748edc9e6c5207ba08c9d4c1a28e
Appending input value fingerprint for 'javaCompiler.metadata.taskInputs.languageVersion' to build cache key: bcff748edc9e6c5207ba08c9d4c1a28e
Appending input value fingerprint for 'javaVersion' to build cache key: d1bf624d05a9b86fc4583c4a07e4ae30
Build cache key for task ':tidepool-model:compileJava' is 0b767ff69ffa86da3753627d7ca3f6be
$ ./gradlew clean compileJava --build-cache -Dorg.gradle.caching.debug=true -Pjdk=21
Appending input value fingerprint for 'javaCompiler.metadata.languageVersion' to build cache key: a45bf5567def0eff565dee4e46a6744a
Appending input value fingerprint for 'javaCompiler.metadata.taskInputs.languageVersion' to build cache key: a45bf5567def0eff565dee4e46a6744a
Appending input value fingerprint for 'javaVersion' to build cache key: 1705946bb0c9ba3b56c322640f6c06da
Build cache key for task ':tidepool-model:compileJava' is 554ae1d183c8f740e983283586461482
The consequences for a team: all machines that share a remote cache must build with the same JDK (a toolchain pins it), or each JDK gets its own entries; and the CI image should not change its JDK silently. The fingerprints printed for the compile task contain the language version, the Java version, and the vendor and the implementation of the compiler (javaCompiler.metadata.taskInputs.vendor and javaCompiler.metadata.taskInputs.implementation); no installation path appears among them.
Debugging cache misses
When a task that should have been FROM-CACHE was executed, the question is which input differed. The official guide gives a procedure that works because it narrows the search step by step:
-
Make the build correct without the cache first. Run
cleanand the build twice without--build-cache; every task that produces files should beUP-TO-DATEthe second time. A task that is not up to date in an unchanged workspace is a task with unstable inputs or outputs, and it will also miss in the cache (see Gradle Incremental Build for how to find out why). Tasks with no outputs always run, which does not matter. -
In-place test. Empty the local cache,
clean, build with--build-cache,clean, build again. Every cacheable task must beFROM-CACHE(the first listing of this page). -
Relocation test. Check the same commit out twice, build in one,
cleanand build in the other with the same local cache. Absolute paths among the inputs show up here (theStationSummarylisting above). Run it with no remote cache configured and with push on. -
Cross-platform test. If developers and CI use different operating systems, populate a remote cache from one and consume it from the other; line endings, file permissions and the JDK distribution are the usual differences.
-
Incremental use. Make typical changes (a comment, a method body, a new class, a dependency bump) and watch how much is still cached; if too little, restructure the project to reduce the dependencies between tasks.
For a single miss, collect the data in the order from coarse to fine: the build cache key; the implementation of the task and its actions (class names and class loader hashes); the names of the output properties; the hashes of the individual input properties; and the hashes of the files in the file inputs. --info gives the first, -Dorg.gradle.caching.debug=true gives all the others. The workflow with two builds is mechanical: save the debug output of both, strip the paths with sed if the locations differ, and diff them. The lines that differ are the answer.
./gradlew clean :tidepool-engine:test --build-cache -Dorg.gradle.caching.debug=true > build-a.txt
./gradlew clean :tidepool-engine:test --build-cache -Dorg.gradle.caching.debug=true > build-b.txt
diff build-a.txt build-b.txt
Remember the cascade: if a task is not loaded from the cache and its outputs differ, every task that consumes them misses as well, so the miss to investigate is the first cacheable task that was executed, not the last. The causes found most often, each of which was reproduced on this page:
| Cause | How it shows | Fix |
|---|---|---|
An input declared with |
|
|
A volatile file on the runtime classpath (time stamp, commit id, build number) |
The hash of the JAR or directory in |
Do not write it into resources; or |
The JDK differs |
|
Pin the JDK with a toolchain |
The build script changed |
The implementation hash of a script-defined task differs, |
Move the task into |
An input property that holds a value of the environment (a system property, an environment variable, a host name) |
A value fingerprint differs |
Do not pass it to a cacheable task, or normalize it |
Overlapping outputs |
|
Give each task its own output location |
A task that does not clean its outputs, so that stale files end up in the stored entry |
A cached build behaves differently from a clean one |
Make the task delete stale outputs (cache only clean builds until it does) |
A Develocity Build Scan removes the manual work: its Build Scan Comparison shows the inputs of the same task in two builds side by side, and its timeline lists the cacheable tasks that ran. Not run here; see Performance Profiling & Build Scans.
Using the cache on a team
A sequence that has worked for many teams, in the order in which it reduces risk:
-
Local cache for everybody.
org.gradle.caching=truein the project’sgradle.properties. A single developer already gains from branch switching andgit bisect. -
Make the build cache-safe. Run the in-place and relocation tests above until every cacheable task is a hit; fix the declarations; put the build logic in
build-logicso that script edits do not change keys. -
Remote cache, CI only. Start with the cache on CI agents, whose environment is stable, with the node reachable by them only. Push from CI after a
cleanbuild of a checked-out commit, never from a build that restores a previous workspace. -
Developers pull. Give developers read access to the node. The first build of the day, after pulling everybody’s changes, is then mostly
FROM-CACHEfor everything that CI already built. -
Keep the local cache next to the remote one: on CI it mirrors the remote cache and saves network traffic, on a laptop it makes branch switching free.
-
Do not let developers push until the build has proven that incremental builds are flawless. The cache exposes stale-output problems in tasks and plugins that incremental builds hide, which is useful, but until the build has been fixed only clean builds should upload.
-
Audit requirements. If you must prove which build produced a shipped artifact, disable the cache for the release build, or use a service that records which build produced which entry.
The build cache accelerates tasks; it does not replace the dependency cache or the wrapper (a CI job still needs both, kept between jobs or restored from a CI cache), and it works together with parallel execution, the daemon and the configuration cache, which are the other items of the performance checklist on Performance Profiling & Build Scans and Best Practices & Troubleshooting.
Messages you will meet
| Message | Meaning | What to do |
|---|---|---|
|
An archive or copy task |
Nothing; this is by design. |
|
A custom or third-party task type that is not cacheable |
Annotate with |
|
A |
Declare the normalization. |
|
An |
Expected; or change the predicate. |
|
Two tasks write to the same location |
Separate the outputs. |
|
The node requires credentials |
Add |
|
The node is down or the URL is wrong |
Check the node; the build continues without it. |
|
A request failed after the retries; the rest of the build runs without the remote cache |
Fix the node; the warning is printed again at the end. |
|
|
Expected. |
|
The cache is not consulted for this run |
Expected. |
|
The Boolean properties are |
Rename. |
The same idea in Maven and Ant
| Aspect | Gradle | Maven | Ant |
|---|---|---|---|
Mechanism |
Built in: every task declares inputs and outputs; the key is computed from them |
The Apache Maven Build Cache Extension 1.3.0, a core extension that keys a whole module build on its sources, dependencies and effective POM |
None. Time-stamp comparisons written by hand ( |
Unit cached |
A task |
A module (all the plugin executions that produced its output) |
- |
Shared between checkouts |
Yes, by key |
Yes, by key; the plugin parameters are part of the key |
No |
Remote cache |
HTTP node, Develocity |
A remote cache of the extension |
No |
Correctness depends on |
The completeness of the declared inputs and outputs of each task |
Which files and plugin parameters the extension is configured to hash (guessed inputs, with reconciliation rules) |
- |
The comparison is expanded in Comparing Ant, Maven & Gradle; the Maven extension is covered, with its configuration and its pitfalls, in Maven Build Performance. The structural difference is that Gradle caches at the granularity of the task and relies on declarations that the task author wrote once, while Maven’s extension has to guess from the POM which parameters influence the result.
Gradle 8.14 and later
What is on this page is the behaviour of 9.8.1; the differences with the 8.x line, from the release notes and upgrade guides (not run here, Gradle 8.14.x was not installed):
-
The build cache itself, the key, the
@CacheableTaskrules and the HTTP protocol are the same in 8.14 and 9.8.1. -
Before 8.8 the retention of the local cache was set with
buildCache { local { removeUnusedEntriesAfterDays = n } }and the cleanup ran every 24 hours; from 8.8 the init scriptcaches { buildCache... }is the way, and the old property is deprecated; it is removed in 9.0.0 (the settings file then fails to compile). -
Gradle 8.7 stored Groovy build script compilation in the remote cache; 8.8 stopped doing that.
-
Gradle 9.8.0 deprecated enabling the build cache from build logic (
gradle.startParameter.buildCacheEnabled = truein a settings file or init script), with removal in Gradle 10;--build-cacheandorg.gradle.cachingremain the switches. -
Gradle 9.7 stopped storing the Kotlin DSL accessor generation in the build cache; the log line
Build caching of Kotlin script accessor generation disabled by propertyappears in--infooutput of 9.8.1. -
The configuration cache is the preferred mode since 9.0 (and announced as the default for Gradle 10); a task action that uses
Projectat execution time, such as the predicate in the failing example above, is incompatible with it. See Configuration Cache & Isolated Projects. The upgrade path is on Upgrading Gradle.
Where the book is outdated
Gradle in Action describes Gradle 1.7, which has no build cache (it arrived in 3.5, four years later). The practices of that book that this page changes or extends:
| Book statement or practice | Current status | Source |
|---|---|---|
No build cache; speed comes from incremental builds only |
The build cache was introduced in 3.5 and has been production-ready since 4.0 |
|
Task inputs and outputs for up-to-date checks |
The same declarations also feed the cache key, and since the cache is shared they must be complete and relocatable ( |
|
Custom task classes with |
Add |
|
|
|
|
|
Logic in scripts is part of the cache key; shared logic belongs in |
The two other books of the series have no counterpart: the Ant book’s closest concept is <uptodate>, and the Maven 2 book predates the Maven Build Cache Extension.
Summary
-
The build cache stores the outputs of cacheable tasks under a key computed from the task implementation, its actions, the input values, the normalized input files and the names of the outputs. A hit is
FROM-CACHE, and a task is only consulted after the up-to-date check fails. -
Enable it with
--build-cacheororg.gradle.caching=true; the local cache in$GRADLE_USER_HOME/caches/build-cache-1is cleaned up after 7 days of non-use by default, configurable in an init script. -
Copy,JarandZipare deliberately not cacheable; compilation, Javadoc andTestare. Custom tasks opt in with@CacheableTaskand must declare a path sensitivity; ad hoc tasks useoutputs.cacheIf. -
A remote HTTP cache is a
GET/PUTnode; let CI push (from clean builds) and let developers pull; credentials and TLS belong to the node, and the push right is a supply-chain right. -
Relocatability (
@PathSensitive), stable inputs, input normalization, the JDK and the build script are what usually decide whether the cache works;--infoand-Dorg.gradle.caching.debug=trueplusdifffind the input that differs.