Gradle Configuration Cache & Isolated Projects

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 (released on 7 October 2026, the current release on 2026-10-16), with Gradle 8.14.6, the last 8.x release, mentioned where it differs. Every example marked as run was executed with the 9.8.1 wrapper on 2026-10-16, on macOS (Apple silicon), in scratch copies of the tidepool build used throughout this section (a model library, an engine library that reads a CSV file with Commons CSV, and a command line application that uses picocli), once with *.gradle.kts files and once with *.gradle files. The Gradle daemon ran on Oracle JDK 26 (java version "26"), the machine’s default JDK; the modules compile with a Java 21 toolchain, which is a JetBrains Runtime (JBR) 21.0.10 that Gradle found on the machine (see Gradle Toolchains). Every run used a Gradle User Home created for the page (the GRADLE_USER_HOME environment variable), so that no daemon or cache of the real ~/.gradle was touched; its gradle.properties holds org.gradle.java.installations.auto-download=false, so that no JDK could be downloaded, and org.gradle.jvmargs=-Xmx512m -Duser.language=en -Duser.country=US, so that the JVM messages are in English. In the listings the scratch directory is shown as <work>, output is shortened only by leaving out, in some listings, blank lines, the summary line N actionable tasks, the * Try: hints that end a failure message or the > Task lines, and the --console=plain option of the runs is left out of the command lines. Gradle 8.14.x was not available here, so what is said about it comes from the official release notes and upgrade guides and is labelled "not run here"; five more things were not run, and are labelled the same way: the Gradle properties org.gradle.configuration-cache.parallel and org.gradle.configuration-cache.integrity-check, the STABLE_CONFIGURATION_CACHE feature preview, a configuration cache shared between machines (it does not exist yet), and IDE syncs (no IDE was used).

The configuration cache is the answer to a cost that every Gradle build pays on every run: before the first task can start, Gradle has to execute the settings file, every build script and every plugin, to find out which tasks exist and how they depend on each other (see The Gradle Build Lifecycle). That is the configuration phase, and in a large build it can take longer than the tasks themselves. With the configuration cache, Gradle stores the result of that phase and, if nothing it depended on has changed, loads it instead of computing it again. This page covers how that works, how to switch it on, what invalidates it, the rules that build logic must follow so that it can be stored, how to read the problem report, what it does to task execution, and what its status is. The second half covers Isolated Projects, the incubating feature built on top of it that makes the configuration of the subprojects of a multi-project build run in parallel. The related caches are separate pages: the build cache reuses task outputs (Gradle Build Cache) and the dependency cache stores downloaded files (Dependency Locking, Verification & Caching).

The tidepool build used on this page

All the examples use the tidepool build. The settings file declares the repository once, as the other pages of this section do, and includes the three modules:

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

dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
}

include("tidepool-model", "tidepool-engine", "tidepool-cli")
Groovy DSL: settings.gradle
rootProject.name = 'tidepool'

dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
}

include 'tidepool-model', 'tidepool-engine', 'tidepool-cli'

The versions live in a version catalog (see Gradle Version Catalogs):

gradle/libs.versions.toml
[versions]
commons-csv = "1.14.1"
picocli = "4.7.7"

[libraries]
commons-csv = { module = "org.apache.commons:commons-csv", version.ref = "commons-csv" }
picocli = { module = "info.picocli:picocli", version.ref = "picocli" }

Every module asks for a Java 21 toolchain. This is tidepool-engine; tidepool-model is the same without the dependencies, and tidepool-cli applies the application plugin, depends on tidepool-engine and libs.picocli, and sets mainClass:

Kotlin DSL: tidepool-engine/build.gradle.kts
plugins {
    `java-library`
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

dependencies {
    api(project(":tidepool-model"))
    implementation(libs.commons.csv)
}
Groovy DSL: tidepool-engine/build.gradle
plugins {
    id 'java-library'
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

dependencies {
    api project(':tidepool-model')
    implementation libs.commons.csv
}

The wrapper is the 9.8.1 one, with the checksum of the distribution pinned (see Gradle Wrapper). The runs used a copy of the distribution on the local disk instead of the download address below, which is otherwise identical:

gradle/wrapper/gradle-wrapper.properties
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 root project of each experiment below has a small build.gradle.kts or build.gradle that is shown at its place. Each experiment starts from an empty .gradle/configuration-cache directory unless it says otherwise. Plain ./gradlew build is not used: the experiments run the smallest tasks that show the point, so that the listings stay short.

These files are this page’s own setup for the examples, not an example taken from the manual.

How the configuration cache works

Without the configuration cache, every invocation goes through the three phases of the build lifecycle: initialization (find the settings file and the projects), configuration (run the init scripts, the settings file, buildSrc, the build scripts and the plugins, then calculate the task graph) and execution. With it, Gradle writes the result of configuration to disk the first time and, at the start of the next invocation, decides whether it can load that result instead:

flowchart TD A["Invocation: requested tasks and arguments"] --> B{"Entry stored for this key?"} B -- no --> M["Cache miss: run the configuration phase"] B -- yes --> C{"Every recorded input unchanged?"} C -- no --> M C -- yes --> H["Cache hit: skip the configuration phase"] M --> S["Serialize the task graph into .gradle/configuration-cache"] S --> L["Load the graph back from the entry"] H --> L L --> X["Execute the tasks, in parallel where they are independent"]

The pieces, from the manual:

  • The key of an entry is the set of requested tasks, their arguments and some environment information; ./gradlew build and ./gradlew check have different entries.

  • The entry is a snapshot of the task graph: which tasks run, in which order, with the complete state of every task instance (every field, including private ones) and the objects reachable from it. Gradle does not use Java serialization for this, although it understands classes that implement Externalizable or define writeObject and readObject.

  • Next to the snapshot is a fingerprint, the list of everything configuration read from the outside world (see the next section). On a hit Gradle first replays the fingerprint to check that it still holds, and only then deserializes the graph.

  • Even on a miss, Gradle loads the graph back from the entry it has just written and executes the loaded copy. That makes hits and misses run through the same code, so a task that cannot survive serialization fails on the first run with the cache on, not on a later one. It also means that the parallel execution described below is available on a miss.

  • On a hit nothing from configuration runs again: init scripts, the settings file, build scripts and plugins are not evaluated. Code that was registered during configuration to be called later, such as gradle.taskGraph.whenReady { }, does not run either.

The configuration cache and the build cache are different things that compose:

Configuration cache Build cache

What it stores

The result of the configuration phase: the task graph, each task’s configured state and the dependency information.

The outputs of tasks, and of artifact transforms.

Cache key

The build logic and environment: scripts, init scripts, environment variables and properties read at configuration time, and more.

Each task’s declared inputs: sources, classpath, the implementation of the task action.

What it avoids

The configuration phase, on a hit.

The execution of a task whose inputs have been seen before.

Where it lives

.gradle/configuration-cache in the root of the build, local only.

The Gradle User Home, or a remote cache (see Gradle Build Cache).

The first run, and the second

The first experiment shows what a miss and a hit look like. The root project has a build script that prints when it is configured and when the task graph is ready, and that sleeps for two seconds, to stand in for an expensive configuration phase (a real build gets this from many plugins and many projects; the sleep is only a way to make the effect visible in a build this small):

Kotlin DSL: build.gradle.kts
println("configuring the root project")
Thread.sleep(2000) // stands in for an expensive configuration phase

gradle.taskGraph.whenReady {
    println("task graph ready: ${allTasks.size} tasks")
}
Groovy DSL: build.gradle
println 'configuring the root project'
Thread.sleep(2000) // stands in for an expensive configuration phase

gradle.taskGraph.whenReady {
    println "task graph ready: ${it.allTasks.size()} tasks"
}

First without the configuration cache. The tasks are already up to date, so all the time goes to configuration. The last line is a hint that Gradle 9 prints at the end of a build that has no known incompatibility with the cache but does not use it:

Output (Gradle 9.8.1, no configuration cache)
$ ./gradlew :tidepool-engine:jar

> Configure project :
configuring the root project
task graph ready: 5 tasks

> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tidepool-engine:compileJava UP-TO-DATE
> Task :tidepool-engine:processResources NO-SOURCE
> Task :tidepool-engine:classes UP-TO-DATE
> Task :tidepool-engine:jar UP-TO-DATE

BUILD SUCCESSFUL in 2s
3 actionable tasks: 3 up-to-date
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.8.1/userguide/configuration_cache_enabling.html

Now with --configuration-cache: the first run is a miss, which configures the build and stores an entry; the second is a hit:

Output (Gradle 9.8.1, first run with --configuration-cache: a miss)
$ ./gradlew --configuration-cache :tidepool-engine:jar
Calculating task graph as no cached configuration is available for tasks: :tidepool-engine:jar

> Configure project :
configuring the root project
task graph ready: 5 tasks

> Task :tidepool-engine:processResources NO-SOURCE
> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tidepool-engine:compileJava UP-TO-DATE
> Task :tidepool-engine:classes UP-TO-DATE
> Task :tidepool-engine:jar UP-TO-DATE

BUILD SUCCESSFUL in 2s
3 actionable tasks: 3 up-to-date
Configuration cache entry stored.
Output (Gradle 9.8.1, second run: a hit)
$ ./gradlew --configuration-cache :tidepool-engine:jar
Reusing configuration cache.
> Task :tidepool-engine:processResources NO-SOURCE
> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tidepool-engine:compileJava UP-TO-DATE
> Task :tidepool-engine:classes UP-TO-DATE
> Task :tidepool-engine:jar UP-TO-DATE

BUILD SUCCESSFUL in 444ms
3 actionable tasks: 3 up-to-date
Configuration cache entry reused.

On the miss the two lines printed by the build script appear (under > Configure project :); on the hit they do not, because the build script did not run, and neither did the taskGraph.whenReady callback. The build took 2 seconds without the cache, 2 seconds on the miss and 444 milliseconds on the hit. The Groovy DSL build printed the same lines, with 7 seconds for the first run (which also compiled the Java sources), 2 seconds for the miss and 545 milliseconds for the hit. On the hit the tasks are listed in a different order from the miss: the loaded graph is executed in parallel, which is the subject of a later section.

The entry is a directory under .gradle/configuration-cache in the root of the build:

Output: the files of the entry after the two runs
$ find .gradle/configuration-cache -type f | sort
.gradle/configuration-cache/<entry>/.globals.work.bin
.gradle/configuration-cache/<entry>/.strings.work.bin
.gradle/configuration-cache/<entry>/_tidepool-engine.work.bin
.gradle/configuration-cache/<entry>/_tidepool-model.work.bin
.gradle/configuration-cache/<entry>/buildfingerprint.bin
.gradle/configuration-cache/<entry>/classloaderscopes.bin
.gradle/configuration-cache/<entry>/entry.bin
.gradle/configuration-cache/<entry>/projectfingerprint.bin
.gradle/configuration-cache/<entry>/work.bin
.gradle/configuration-cache/6akkzvijxbpytuvj5lyra5v48/candidates.bin
.gradle/configuration-cache/configuration-cache.lock
.gradle/configuration-cache/gc.properties

The directory named after the entry (a UUID) is shown as <entry>. entry.bin and work.bin hold the task graph, the _<project>.work.bin files hold the state that belongs to each project, buildfingerprint.bin and projectfingerprint.bin hold the recorded inputs, and candidates.bin, in a directory named after a hash, looks like an index of the entries stored for a key (that is a guess from the file names; the manual does not describe it). The files are binary and encrypted, and are not meant to be read.

Enabling the configuration cache

By default, Gradle 9.8.1 does not use the configuration cache. There are two ways to ask for it, and one to switch it off again:

Command line Gradle property (gradle.properties) Effect

--configuration-cache

org.gradle.configuration-cache=true

Use the cache for this build, or for every build.

--no-configuration-cache

org.gradle.configuration-cache=false

Do not use it. The command line wins over the property, so --no-configuration-cache switches off a cache that gradle.properties turned on. Setting false in the file also silences the Gradle 9 hint described below.

--configuration-cache-problems=warn

org.gradle.configuration-cache.problems=warn

Report problems as warnings instead of failing the build. The default is fail. A migration aid, discussed below.

-Dorg.gradle.configuration-cache.max-problems=5

org.gradle.configuration-cache.max-problems=5

Stop after this many problems. The default is 512, which only matters in warning mode.

-Dorg.gradle.configuration-cache.read-only=true

org.gradle.configuration-cache.read-only=true

Use entries but never write them (incubating, 9.1.0).

-Dorg.gradle.configuration-cache.parallel=true

org.gradle.configuration-cache.parallel=true

Store entries in parallel (incubating; not run here).

-Dorg.gradle.configuration-cache.integrity-check=true

org.gradle.configuration-cache.integrity-check=true

Stricter checks when writing and reading, for tracking down serialization bugs (8.14; not run here).

The list of all org.gradle.* properties is in Build Environment & Properties, and the command line options are in The Gradle Command Line Interface. To keep the cache on for a project, commit the property:

gradle.properties
org.gradle.jvmargs=-Xmx512m -Duser.language=en -Duser.country=US
org.gradle.java.installations.auto-download=false
org.gradle.configuration-cache=true

With that file, a plain ./gradlew hello stores an entry on the first run and reuses it on the second, and --no-configuration-cache turns it off for one build (the root project has a task hello that prints a line):

Output (Gradle 9.8.1)
$ ./gradlew hello
Calculating task graph as no cached configuration is available for tasks: hello
> Task :hello
hello from tidepool
BUILD SUCCESSFUL in 975ms
Configuration cache entry stored.

$ ./gradlew hello
Reusing configuration cache.
> Task :hello
hello from tidepool
BUILD SUCCESSFUL in 873ms
Configuration cache entry reused.

$ ./gradlew hello --no-configuration-cache
> Task :hello
hello from tidepool
BUILD SUCCESSFUL in 766ms

IDEs that delegate builds to Gradle pick up gradle.properties, so the property above enables the cache for the whole team, which is usually what is wanted. To enable it only in your IDE, add -Dorg.gradle.configuration-cache=true to the VM options of the Gradle run configuration template in IntelliJ IDEA (Run > Edit configurations > Templates > Gradle), or use the Gradle preferences of Buildship in Eclipse. Only running tasks from the IDE uses the cache; the project import and sync do not (not run here: no IDE was used). The manual says not to put problems=warn into an IDE configuration.

The Gradle 9 hint

The first listing of this page ended with "Consider enabling configuration cache to speed up this build". Gradle 9.0.0 made the configuration cache the preferred mode of execution: it is still off by default, but a build that has no known incompatibility prints that hint, to encourage adoption. The release notes say that some problems can only be found with the cache on, so the hint is not a guarantee that the build is compatible. The way to say "not yet" is org.gradle.configuration-cache=false in gradle.properties.

Detecting the cache from build logic

A plugin can adapt to the cache, for example to disable an optional feature that is not yet compatible, by asking the BuildFeatures service. The manual says to use this sparingly, because the goal is the same behaviour with and without the cache. The service is injected, which a build script does through a small interface:

Kotlin DSL: build.gradle.kts
import org.gradle.api.configuration.BuildFeatures

interface Injected {
    @get:Inject
    val buildFeatures: BuildFeatures
}

val features = objects.newInstance<Injected>().buildFeatures

tasks.register("hello") {
    val requested = features.configurationCache.requested.orNull
    val active = features.configurationCache.active.get()
    doLast {
        println("configuration cache requested=$requested active=$active")
    }
}
Groovy DSL: build.gradle
import org.gradle.api.configuration.BuildFeatures

interface Injected {
    @Inject
    BuildFeatures getBuildFeatures()
}

def features = objects.newInstance(Injected).buildFeatures

tasks.register('hello') {
    def requested = features.configurationCache.requested.orNull
    def active = features.configurationCache.active.get()
    doLast {
        println "configuration cache requested=$requested active=$active"
    }
}
Output (Gradle 9.8.1, Kotlin DSL; the Groovy DSL printed the same)
$ ./gradlew --no-configuration-cache hello
> Task :hello
configuration cache requested=false active=false
BUILD SUCCESSFUL in 1s

$ ./gradlew --configuration-cache hello
Calculating task graph as no cached configuration is available for tasks: hello
> Task :hello
configuration cache requested=true active=true
BUILD SUCCESSFUL in 821ms
Configuration cache entry stored.

What invalidates an entry

An entry is valid as long as every input that configuration read has the same value. The manual lists them:

Input Examples

The Gradle environment

GRADLE_USER_HOME and the daemon JVM.

Build logic

Init scripts; the contents of buildSrc and of included builds that provide build logic; build and settings scripts, including scripts applied with apply from.

Gradle configuration files

Version catalogs, dependency verification files, dependency lock files and gradle.properties files.

Files read at configuration time, and the file system state checked then

The contents of any file the build reads; whether a file exists; the contents of a directory.

Values obtained at configuration time

System properties, environment variables, ValueSource values (including the built-in providers.exec and providers.fileContents), and Gradle properties (-P).

Gradle records these inputs while the configuration phase runs. To see them without declaring anything, it instruments the bytecode of build scripts and plugins with a Java agent, which detects reads such as System.getenv. The next experiment reads one file, one system property and one environment variable while the task is configured, and prints them when it runs. The values are read inside the configuration block of the task, not at the top of the script; the reason is explained in the requirements section below.

Kotlin DSL: build.gradle.kts
tasks.register("report") {
    val stations = file("data/stations.csv").readLines().size - 1
    val region = System.getProperty("tidepool.region", "global")
    val stage = System.getenv("TIDEPOOL_STAGE") ?: "dev"
    doLast {
        println("stations=$stations region=$region stage=$stage")
    }
}
Groovy DSL: build.gradle
tasks.register('report') {
    def stations = file('data/stations.csv').readLines().size() - 1
    def region = System.getProperty('tidepool.region', 'global')
    def stage = System.getenv('TIDEPOOL_STAGE') ?: 'dev'
    doLast {
        println "stations=$stations region=$region stage=$stage"
    }
}

The runs below store an entry, reuse it, and then change one input at a time. After each change Gradle says why the entry could not be used. The > Task lines and the blank lines are left out:

Output (Gradle 9.8.1, Kotlin DSL; the Groovy DSL printed the same messages with build.gradle)
$ ./gradlew --configuration-cache report
Calculating task graph as no cached configuration is available for tasks: report
stations=3 region=global stage=dev
BUILD SUCCESSFUL in 506ms
Configuration cache entry stored.

$ ./gradlew --configuration-cache report
Reusing configuration cache.
stations=3 region=global stage=dev
BUILD SUCCESSFUL in 524ms
Configuration cache entry reused.

$ TIDEPOOL_STAGE=ci ./gradlew --configuration-cache report
Calculating task graph as configuration cache cannot be reused because environment variable 'TIDEPOOL_STAGE' has changed.
stations=3 region=global stage=ci
BUILD SUCCESSFUL in 825ms
Configuration cache entry stored.

$ ./gradlew --configuration-cache report -Dtidepool.region=atlantic
Calculating task graph as configuration cache cannot be reused because system property 'tidepool.region' has changed.
stations=3 region=atlantic stage=dev
BUILD SUCCESSFUL in 706ms
Configuration cache entry stored.

$ echo "OSL,Oslo,59.9,10.7,M2:0.4:10" >> data/stations.csv
Calculating task graph as configuration cache cannot be reused because file 'data/stations.csv' has changed.
stations=4 region=global stage=dev
BUILD SUCCESSFUL in 497ms
Configuration cache entry stored.

$ echo "// touched" >> build.gradle.kts
Calculating task graph as configuration cache cannot be reused because file 'build.gradle.kts' has changed.
stations=4 region=global stage=dev
BUILD SUCCESSFUL in 477ms
Configuration cache entry stored.

The reason is always specific: "environment variable 'TIDEPOOL_STAGE' has changed", "system property 'tidepool.region' has changed", "file 'data/stations.csv' has changed", "file 'build.gradle.kts' has changed". Because the script reads these values at configuration time, any change to them re-runs configuration, even when the change would not have mattered for the tasks that were requested.

Read lazily, and the entry survives

The remedy for needless invalidation is not to read at configuration time but to wire a provider into the task, so that the value is read when the task runs (see Lazy Configuration & Providers). The same experiment, with a task class whose inputs are properties (providers.systemProperty and providers.environmentVariable return providers that are resolved at execution time):

Kotlin DSL: build.gradle.kts
abstract class Report : DefaultTask() {
    @get:Input
    abstract val region: Property<String>

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

    @TaskAction
    fun run() {
        println("region=${region.get()} stage=${stage.get()}")
    }
}

tasks.register<Report>("report") {
    region = providers.systemProperty("tidepool.region").orElse("global")
    stage = providers.environmentVariable("TIDEPOOL_STAGE").orElse("dev")
}
Groovy DSL: build.gradle
abstract class Report extends DefaultTask {
    @Input
    abstract Property<String> getRegion()

    @Input
    abstract Property<String> getStage()

    @TaskAction
    void run() {
        println "region=${region.get()} stage=${stage.get()}"
    }
}

tasks.register('report', Report) {
    region = providers.systemProperty('tidepool.region').orElse('global')
    stage = providers.environmentVariable('TIDEPOOL_STAGE').orElse('dev')
}
Output (Gradle 9.8.1, Kotlin DSL; the Groovy DSL printed the same)
$ ./gradlew --configuration-cache report
Calculating task graph as no cached configuration is available for tasks: report
> Task :report
region=global stage=dev
BUILD SUCCESSFUL in 2s
Configuration cache entry stored.

$ ./gradlew --configuration-cache report -Dtidepool.region=atlantic
Reusing configuration cache.
> Task :report
region=atlantic stage=dev
BUILD SUCCESSFUL in 1s
Configuration cache entry reused.

$ TIDEPOOL_STAGE=ci ./gradlew --configuration-cache report
Reusing configuration cache.
> Task :report
region=global stage=ci
BUILD SUCCESSFUL in 615ms
Configuration cache entry reused.

Both changed values reach the task, and both runs say "Reusing configuration cache". The task still runs again, because its inputs changed, but the configuration phase does not. This is the difference between a build that re-configures on every change of the environment and one that does not, and it is the main reason the manual recommends the provider APIs.

Gradle properties on the command line

Before Gradle 9.1.0, changing any -P property on the command line invalidated the entry, even if the build never looked at it. Since 9.1.0 only the properties that configuration actually read count. In a build whose configuration does not read the property note, the entry is reused whatever -Pnote is:

Output (Gradle 9.8.1, Kotlin DSL; the root build is the lazy report task above)
$ ./gradlew --configuration-cache report -Pnote=1
Reusing configuration cache.
region=global stage=dev
Configuration cache entry reused.

$ ./gradlew --configuration-cache report -Pnote=2
Reusing configuration cache.
region=global stage=dev
Configuration cache entry reused.

Once the script reads it at configuration time (val note = findProperty("note")), the property becomes an input. The first line below is the change of the build script itself; the second shows the property:

Output (Gradle 9.8.1, Kotlin DSL)
$ ./gradlew --configuration-cache report -Pnote=1
Calculating task graph as configuration cache cannot be reused because file 'build.gradle.kts' has changed.
region=global stage=dev
Configuration cache entry stored.

$ ./gradlew --configuration-cache report -Pnote=2
Calculating task graph as configuration cache cannot be reused because Gradle property 'note' has changed.
region=global stage=dev
Configuration cache entry stored.

Invalidating by hand, and how long entries live

Gradle invalidates automatically when a tracked input changes. If you have ignored problems, or if a build reads something that Gradle cannot see, you may need to delete the cache yourself:

$ rm -rf .gradle/configuration-cache

Gradle also cleans up after itself: at most every 24 hours it checks whether entries are still in use, and entries unused for 7 days are deleted. The cache lives with the project, so a fresh checkout, a clean CI agent or a deleted .gradle directory starts with a miss.

Temporary opt-outs

Every Gradle release tightens the cache in two directions: it detects more reads of the environment, so that a changed value correctly invalidates, and it requires more things to be serializable. When a stricter rule breaks a build whose problem is in a plugin you cannot fix, the manual offers properties that revert to the old behaviour. They are meant to be temporary and will be removed in later releases:

Property What it reverts

org.gradle.configuration-cache.inputs.unsafe.ignore.file-system-checks

Tracking of File.exists(), File.isFile() and similar checks as inputs. The value is a list of paths relative to the root project, separated by ;, with * and ** wildcards; a path starting with ~/ is relative to the home directory.

org.gradle.configuration-cache.inputs.unsafe.ignore.in-serialization

Gradle 8.4 started to track undeclared inputs that are read while the task graph is serialized; set to true for the earlier behaviour.

org.gradle.configuration-cache.unsafe.ignore.unsupported-build-events-listeners

Since 9.0.0 it is an error to register a provider other than a build service as a task completion listener; earlier versions silently dropped them.

org.gradle.configuration-cache.unsafe.skip-task-logging-listeners-serialization

Since 9.8.0 standard output and error listeners registered on a task’s logging manager during configuration are stored and re-registered on a hit, so one that cannot be serialized is a problem; earlier versions dropped them.

Requirements for build logic

To store the task graph, Gradle must be able to turn every scheduled task, and everything the task refers to, into bytes and back, and the result of configuration must not depend on anything that Gradle did not see. That puts rules on build scripts, plugins and tasks. The manual calls them requirements. Most violations are detected and reported as problems, which fail the build; some cannot be detected (a process started through a code path that Gradle does not instrument is the example the manual gives) and produce stale results on a hit, because the value is not tracked as an input. In nearly all cases a requirement exposes an undeclared input, so that using the cache is, as the manual puts it, an opt-in to a stricter and more correct build. The sections below take the rules that break builds most often, each with a real failure and the repair.

Do not use the Project object while a task runs

A task action must not reach the Project, which includes calling Task.getProject() (project in the script) from doFirst, doLast or a @TaskAction method. The Project is the model of the build and is not part of the stored state. The classic example is project.copy { }, and the experiment below uses it: a task that copies the station list into build/export, with its input and output declared so that Gradle can make it incremental:

Kotlin DSL: build.gradle.kts
tasks.register("exportStations") {
    val csv = layout.projectDirectory.file("data/stations.csv")
    val target = layout.buildDirectory.dir("export")
    inputs.file(csv)
    outputs.dir(target)
    doLast {
        project.copy {
            from(csv)
            into(target)
        }
    }
}
Groovy DSL: build.gradle
tasks.register('exportStations') {
    def csv = layout.projectDirectory.file('data/stations.csv')
    def target = layout.buildDirectory.dir('export')
    inputs.file(csv)
    outputs.dir(target)
    doLast {
        project.copy {
            from csv
            into target
        }
    }
}

With --configuration-cache the task starts, then fails, because Gradle refuses the access (Gradle 9.0.0 made the build abort immediately on such a problem, so that the affected task is neither marked up to date nor cached):

Output (Gradle 9.8.1, Kotlin DSL)
$ ./gradlew --configuration-cache exportStations
Calculating task graph as no cached configuration is available for tasks: exportStations
> Task :exportStations FAILED
1 problem was found storing the configuration cache.
- Task `:exportStations` of type `org.gradle.api.DefaultTask`: invocation of 'Task.project' at execution time is unsupported with the configuration cache.
  See https://docs.gradle.org/9.8.1/userguide/configuration_cache_requirements.html#config_cache:requirements:use_project_during_execution
See the complete report at file://<work>/kts/build/reports/configuration-cache/bjhs195hmsrwwpqdysqazbmom/1a10tq7yrbuvi2z8o8c24wlno/configuration-cache-report.html
[Incubating] Problems report is available at: file://<work>/kts/build/reports/problems/problems-report.html
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':exportStations' (registered in build file 'build.gradle.kts').
> Invocation of 'Task.project' by task ':exportStations' at execution time is unsupported with the configuration cache.
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights from a Build Scan (powered by Develocity).
> Get more help at https://help.gradle.org.
BUILD FAILED in 1s
Configuration cache entry discarded with 1 problem.

The Groovy DSL run printed the same, with the message Build file 'build.gradle': line 7: in place of the task description. The link in the output points to the HTML configuration cache report, which lists the problems grouped by message and by task, with the object path that leads to each one and the build configuration inputs that were recorded; the 145 KB report of this run is build/reports/configuration-cache/<hash>/<hash>/configuration-cache-report.html (not shown here: it is a page for a browser).

The first message is the one to remember: "Task :exportStations …​ invocation of 'Task.project' at execution time is unsupported with the configuration cache", with a link to the page that explains it. Notice also "Configuration cache entry discarded with 1 problem": a build with a problem never leaves an entry behind, unless warning mode is on.

The repair is to give the task what it needs as declared inputs and outputs and to ask Gradle for the service that does the work. FileSystemOperations is the injectable replacement for project.copy, project.sync and project.delete; the same idea applies to ExecOperations for processes (Project.exec and Project.javaexec were removed in Gradle 9.0.0) and to Task.getLogger() instead of Project.getLogger():

Kotlin DSL: build.gradle.kts
abstract class ExportStations : DefaultTask() {
    @get:InputFile
    abstract val csv: RegularFileProperty

    @get:OutputDirectory
    abstract val target: DirectoryProperty

    @get:Inject
    abstract val fs: FileSystemOperations

    @TaskAction
    fun export() {
        fs.copy {
            from(csv)
            into(target)
        }
    }
}

tasks.register<ExportStations>("exportStations") {
    csv = layout.projectDirectory.file("data/stations.csv")
    target = layout.buildDirectory.dir("export")
}
Groovy DSL: build.gradle
abstract class ExportStations extends DefaultTask {
    @InputFile
    abstract RegularFileProperty getCsv()

    @OutputDirectory
    abstract DirectoryProperty getTarget()

    @Inject
    abstract FileSystemOperations getFs()

    @TaskAction
    void export() {
        fs.copy {
            from csv
            into target
        }
    }
}

tasks.register('exportStations', ExportStations) {
    csv = layout.projectDirectory.file('data/stations.csv')
    target = layout.buildDirectory.dir('export')
}
Output (Gradle 9.8.1, Kotlin DSL; the Groovy DSL printed the same)
$ ./gradlew --configuration-cache exportStations
Calculating task graph as no cached configuration is available for tasks: exportStations
> Task :exportStations
BUILD SUCCESSFUL in 1s
Configuration cache entry stored.

$ ./gradlew --configuration-cache exportStations
Reusing configuration cache.
> Task :exportStations UP-TO-DATE
BUILD SUCCESSFUL in 1s
Configuration cache entry reused.

Not only does it store, the second run finds the task up to date: the declared inputs and outputs are what let the incremental build machinery (see Gradle Incremental Build) and the configuration cache work together. Writing task classes this way is the subject of Writing Custom Gradle Tasks; the services you can inject are in Worker API & Build Services.

A subtler case is a project property used inside doLast, such as println(version): the lambda captures the project to resolve it. The manual’s fix is to copy it to a local variable inside the task configuration block, val projectVersion = version, and use the variable in the action.

Script-level variables and functions

The first experiment of this page read its values inside the task’s configuration block for a reason. The natural way to write it, with a variable at the top of the script, looks innocent:

Kotlin DSL: build.gradle.kts
val stations = file("data/stations.csv").readLines().size - 1

tasks.register("report") {
    doLast {
        println("stations=$stations")
    }
}

In a Kotlin script, a top-level val is a property of the script class, so the doLast lambda captures the whole script object, which cannot be stored. The task runs, and then the build fails with the problem report:

Output (Gradle 9.8.1, Kotlin DSL)
$ ./gradlew --configuration-cache report
Calculating task graph as no cached configuration is available for tasks: report
> Task :report
stations=3
[Incubating] Problems report is available at: file://<work>/kts/build/reports/problems/problems-report.html
FAILURE: Build failed with an exception.
* What went wrong:
Configuration cache problems found in this build.
1 problem was found storing the configuration cache.
- Task `:report` of type `org.gradle.api.DefaultTask`: cannot serialize Gradle script object references 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
See the complete report at file://<work>/kts/build/reports/configuration-cache/dvxoiknnxwznz9ex84v94k1d5/50998tstzg7df3tr8x1kq83zu/configuration-cache-report.html

The repair is to read the value inside the task configuration block, as the experiments above did, so that the lambda captures a local val; if the value is needed in several places, the manual’s pattern is to shadow the script-level one, val reportFile = reportFile, inside the block. The corrected script stores and reuses its entry:

Kotlin DSL: build.gradle.kts
tasks.register("report") {
    val stations = file("data/stations.csv").readLines().size - 1
    doLast {
        println("stations=$stations")
    }
}
Output (Gradle 9.8.1, Kotlin DSL)
$ ./gradlew --configuration-cache report
Calculating task graph as no cached configuration is available for tasks: report
> Task :report
stations=3
BUILD SUCCESSFUL in 966ms
Configuration cache entry stored.

$ ./gradlew --configuration-cache report
Reusing configuration cache.
> Task :report
stations=3
BUILD SUCCESSFUL in 526ms
Configuration cache entry reused.

The Groovy DSL has the same trap one level up. A local def captured by a closure is fine (the Groovy script with def stations = ... at the top ran and stored its entry), but calling a method defined in the script from doLast is not, because the method belongs to the script object and the task does not have it after loading:

Groovy DSL: build.gradle
def dataDir = file('data')

def countStations(File dir) {
    new File(dir, 'stations.csv').readLines().size() - 1
}

tasks.register('report') {
    doLast {
        println "stations=${countStations(dataDir)}"
    }
}
Output (Gradle 9.8.1, Groovy DSL)
$ ./gradlew --configuration-cache report
Calculating task graph as no cached configuration is available for tasks: report
> Task :report FAILED
FAILURE: Build failed with an exception.
* Where:
Build file '<work>/groovy/build.gradle' line: 9
* What went wrong:
Execution failed for task ':report' (registered in build file 'build.gradle').
> Could not find method countStations() for arguments [<work>/groovy/data] on task ':report' of type org.gradle.api.DefaultTask.
> Get more help at https://help.gradle.org.
BUILD FAILED in 613ms

The message is the manual’s own, and it is the one to recognize: "Could not find method countStations() for arguments …​ on task ':report'". Move the function into a class as a static method (in Kotlin: into an object) and call it as StationFiles.count(dataDir):

Groovy DSL: build.gradle
class StationFiles {
    static int count(File dir) {
        new File(dir, 'stations.csv').readLines().size() - 1
    }
}

def dataDir = file('data')

tasks.register('report') {
    doLast {
        println "stations=${StationFiles.count(dataDir)}"
    }
}
Output (Gradle 9.8.1, Groovy DSL)
$ ./gradlew --configuration-cache report
Calculating task graph as no cached configuration is available for tasks: report
> Task :report
stations=3
BUILD SUCCESSFUL in 527ms
Configuration cache entry stored.

$ ./gradlew --configuration-cache report
Reusing configuration cache.
> Task :report
stations=3
BUILD SUCCESSFUL in 534ms
Configuration cache entry reused.

Dependency resolution is eager

Without the cache, Gradle resolves a configuration only when a task needs its files. A configuration with a dependency that cannot be resolved can go unnoticed for years if no task that runs ever resolves it. With the cache on, the resolved classpath of every scheduled task becomes part of the stored state, so resolution happens at the end of configuration, even for tasks that will be skipped. The manual’s example is a compile task with no source files; so is this one. The build has a single java-library project with a dependency that does not exist:

Kotlin DSL: build.gradle.kts
plugins {
    `java-library`
}

dependencies {
    implementation("io.tidepool:no-such-module:1.0")
}
Groovy DSL: build.gradle
plugins {
    id 'java-library'
}

dependencies {
    implementation 'io.tidepool:no-such-module:1.0'
}
Output (Gradle 9.8.1, Kotlin DSL; the Groovy DSL printed the same)
$ ./gradlew compileJava --no-configuration-cache
> Task :compileJava NO-SOURCE
BUILD SUCCESSFUL in 2s

$ ./gradlew compileJava --configuration-cache
Calculating task graph as no cached configuration is available for tasks: compileJava
1 problem was found storing the configuration cache.
- Task `:compileJava` of type `org.gradle.api.tasks.compile.JavaCompile`: error writing value of type 'org.gradle.api.internal.artifacts.configurations.DefaultResolvableConfiguration'
See the complete report at file://<work>/eager/build/reports/configuration-cache/bchrxvzyf1ccyujtyuyy06itn/ffb07waza5tdt539klf6onyp/configuration-cache-report.html
FAILURE: Build failed with an exception.
* What went wrong:
Configuration cache state could not be cached: field `classpath` of task `:compileJava` of type `org.gradle.api.tasks.compile.JavaCompile`: error writing value of type 'org.gradle.api.internal.artifacts.configurations.DefaultResolvableConfiguration'
> Could not resolve all files for configuration ':compileClasspath'.
   > Could not find io.tidepool:no-such-module:1.0.
     Searched in the following locations:
       - https://repo.maven.apache.org/maven2/io/tidepool/no-such-module/1.0/no-such-module-1.0.pom
     Required by:
         root project 'eager'
> Get more help at https://help.gradle.org.
BUILD FAILED in 1s
Configuration cache entry discarded due to serialization error.

Without the cache, compileJava has no source files (NO-SOURCE), never touches its classpath, and the build succeeds. With the cache, resolution fails while the state is written: the whole entry is discarded ("due to serialization error") and the build fails, with the real resolution error in the middle of the message. Builds that "work" only because a faulty configuration is never resolved will show up the first time the cache is switched on. The manual adds that, although resolution under the cache happens during configuration, build logic should still resolve configurations at execution time: Gradle can optimize the resolution it does for the cache in ways that general build logic cannot use.

Values from outside the build: providers, ValueSource, processes and files

Environment variables, system properties, files and external commands are external information. Gradle tracks the first three when scripts read them with the ordinary APIs, as the invalidation experiments above showed, and offers lazy providers for them so that they are read at execution time. For anything else, and for anything where "any change" invalidates too much, the tool is a ValueSource (see Lazy Configuration & Providers). Its obtain() method runs on every build; only the value it returns is tracked. The cache entry is reused as long as that value is the same. The next experiment decides whether the build is running on a CI server from the presence of the CI variable, and records the operating system with providers.exec:

Kotlin DSL: build.gradle.kts
abstract class RunningOnCi : ValueSource<Boolean, ValueSourceParameters.None> {
    override fun obtain(): Boolean = System.getenv("CI") != null
}

val onCi = providers.of(RunningOnCi::class) {}
val os = providers.exec { commandLine("uname", "-s") }.standardOutput.asText.map { it.trim() }

tasks.register("describe") {
    val mode = if (onCi.get()) "ci" else "local"
    val system = os.get()
    doLast {
        println("mode=$mode os=$system")
    }
}
Groovy DSL: build.gradle
import org.gradle.api.provider.ValueSourceParameters

abstract class RunningOnCi implements ValueSource<Boolean, ValueSourceParameters.None> {
    @Override
    Boolean obtain() {
        return System.getenv('CI') != null
    }
}

def onCi = providers.of(RunningOnCi) {}
def os = providers.exec { commandLine 'uname', '-s' }.standardOutput.asText.map { it.trim() }

tasks.register('describe') {
    def mode = onCi.get() ? 'ci' : 'local'
    def system = os.get()
    doLast {
        println "mode=$mode os=$system"
    }
}
Output (Gradle 9.8.1, Kotlin DSL; the Groovy DSL printed the same)
$ ./gradlew --configuration-cache describe
Calculating task graph as no cached configuration is available for tasks: describe
> Task :describe
mode=local os=Darwin
Configuration cache entry stored.

$ ./gradlew --configuration-cache describe
Reusing configuration cache.
> Task :describe
mode=local os=Darwin
Configuration cache entry reused.

$ CI=true ./gradlew --configuration-cache describe
Calculating task graph as configuration cache cannot be reused because a build logic input of type 'RunningOnCi' has changed.
> Task :describe
mode=ci os=Darwin
Configuration cache entry stored.

$ CI=1 ./gradlew --configuration-cache describe
Reusing configuration cache.
> Task :describe
mode=ci os=Darwin
Configuration cache entry reused.

Setting CI=true flips the value from false to true, so the entry is invalidated, with the message "a build logic input of type 'RunningOnCi' has changed". Changing CI from true to 1 leaves the value true, and the entry is reused. The same condition written with a plain System.getenv("CI") invalidates on the second change as well, because the variable itself, not the decision made from it, is the input:

Output (Gradle 9.8.1, Kotlin DSL, val mode = if (System.getenv("CI") != null) "ci" else "local" in the task block)
$ CI=true ./gradlew --configuration-cache describe
Calculating task graph as no cached configuration is available for tasks: describe
> Task :describe
mode=ci
Configuration cache entry stored.

$ CI=1 ./gradlew --configuration-cache describe
Calculating task graph as configuration cache cannot be reused because environment variable 'CI' has changed.
> Task :describe
mode=ci
Configuration cache entry stored.

Two more rules, both from the manual: processes must not be started at configuration time with ProcessBuilder, Runtime.exec or a Groovy .execute(), nor with ExecOperations.exec (Gradle reports a problem, and even without the cache these calls produce a deprecation warning that will become an error), but through providers.exec and providers.javaexec, or inside a ValueSource that injects ExecOperations; and files read at configuration time are tracked automatically, but a file read through providers.fileContents() becomes a task input instead of a configuration input, which is the better choice when the file only matters to a task.

Build listeners and build services

Callbacks that are registered during configuration to be called during execution cannot survive a hit, because the code that registered them did not run. The manual names BuildListener, TaskExecutionListener, gradle.taskGraph.afterTask and gradle.buildFinished. The first experiment of this page already showed the symptom with gradle.taskGraph.whenReady: its line was printed on the miss and not on the hit. The supported replacement for "observe task execution events" is a build service that implements OperationCompletionListener and is registered with BuildEventsListenerRegistry.onTaskCompletion; for "run something when the build is over" it is the close() of an AutoCloseable service, or a flow action (both are covered in The Gradle Build Lifecycle, which also tabulates the listeners). A minimal listener that counts the tasks finished in a build:

Kotlin DSL: build.gradle.kts
import org.gradle.tooling.events.FinishEvent
import org.gradle.tooling.events.OperationCompletionListener
import org.gradle.tooling.events.task.TaskFinishEvent
import org.gradle.build.event.BuildEventsListenerRegistry

abstract class TaskCounter : BuildService<BuildServiceParameters.None>, OperationCompletionListener, AutoCloseable {
    private var finished = 0

    override fun onFinish(event: FinishEvent) {
        if (event is TaskFinishEvent) {
            finished++
        }
    }

    override fun close() {
        println("tasks finished in this build: $finished")
    }
}

interface Injected {
    @get:Inject
    val events: BuildEventsListenerRegistry
}

val counter = gradle.sharedServices.registerIfAbsent("taskCounter", TaskCounter::class) {}
objects.newInstance<Injected>().events.onTaskCompletion(counter)
Groovy DSL: build.gradle
import org.gradle.api.services.BuildServiceParameters
import org.gradle.build.event.BuildEventsListenerRegistry
import org.gradle.tooling.events.FinishEvent
import org.gradle.tooling.events.OperationCompletionListener
import org.gradle.tooling.events.task.TaskFinishEvent

abstract class TaskCounter implements BuildService<BuildServiceParameters.None>, OperationCompletionListener, AutoCloseable {
    private int finished = 0

    @Override
    void onFinish(FinishEvent event) {
        if (event instanceof TaskFinishEvent) {
            finished++
        }
    }

    @Override
    void close() {
        println "tasks finished in this build: $finished"
    }
}

interface Injected {
    @Inject
    BuildEventsListenerRegistry getEvents()
}

def counter = gradle.sharedServices.registerIfAbsent('taskCounter', TaskCounter) {}
objects.newInstance(Injected).events.onTaskCompletion(counter)
Output (Gradle 9.8.1, Kotlin DSL; the Groovy DSL printed the same line)
$ ./gradlew --configuration-cache :tidepool-engine:jar
Calculating task graph as no cached configuration is available for tasks: :tidepool-engine:jar
> Task :tidepool-engine:processResources NO-SOURCE
> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tidepool-engine:compileJava UP-TO-DATE
> Task :tidepool-engine:classes UP-TO-DATE
> Task :tidepool-engine:jar UP-TO-DATE
tasks finished in this build: 5
BUILD SUCCESSFUL in 875ms
Configuration cache entry stored.

$ ./gradlew --configuration-cache :tidepool-engine:jar
Reusing configuration cache.
> Task :tidepool-engine:processResources NO-SOURCE
> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tidepool-engine:compileJava UP-TO-DATE
> Task :tidepool-engine:classes UP-TO-DATE
> Task :tidepool-engine:jar UP-TO-DATE
tasks finished in this build: 5
BUILD SUCCESSFUL in 394ms
Configuration cache entry reused.

The listener reported five finished tasks on the miss and on the hit, unlike the whenReady callback. Note the shape of the registration: since Gradle 9.0.0 the argument of onTaskCompletion must be a provider that comes from registerIfAbsent (or from a service registration); any other provider is a configuration cache problem, where earlier versions dropped it silently. A service can also hold state that tasks share; with the cache, only the service’s parameters are stored, not the instance, and each build creates the instance again, so the parameter types must be serializable (Property<String> is, Property<Thread> is not).

The other rules, in short

Rule What to do instead

Tasks must not hold live JVM state or Gradle model types: ClassLoader, Thread, streams, sockets, locks and other java.util.concurrent synchronization types; Project, Gradle, Settings; unresolved Configuration objects.

Declare inputs as properties and file collections. Task fields of type Configuration are resolved at store time and come back as a FileCollection, so declare a FileCollection in the first place.

A task must not read the state of another task instance at execution time.

Connect tasks with outputs and inputs (a task output is a provider); a task that configures other tasks at execution time is unsupported.

Every instance field of a task is stored, so tasks that shared a mutable object at configuration time get independent copies.

Share mutable state through a build service. Reference identity is kept for instances of your own classes but not for String, File or most collections.

A task must not read conventions, extensions or extra properties (the ext block) at execution time. Without the cache this is already a deprecation warning, and it becomes an error in Gradle 10.

Model the value as a task property.

Libraries that check their own bytecode can fail, because Gradle rewrites build-logic bytecode with a Java agent to track inputs.

Run them through the Worker API with classloader or process isolation (see Worker API & Build Services). Gradle 9.7.0 also made third-party -javaagent attachments work with the cache and TestKit.

Secrets in task fields end up in the entry.

See the security section below.

Problems, the report and incompatible tasks

By default, a build with a configuration cache problem fails and its entry is discarded; that is what the project.copy experiment showed. The report linked from the output is the main tool, and the manual recommends this routine: read the report, group by message to find the pattern, group by task to find the owner, expand the problem tree to find the field that holds the offending object, follow the link next to the problem to the matching requirement, and test the repair with TestKit (see Testing & Publishing Gradle Plugins) if the code is a plugin.

Warning mode

--configuration-cache-problems=warn lets the build go on, and stores the entry together with its problems. The manual describes it as a short-lived troubleshooting and migration aid and recommends against leaving it on, because a hit then runs tasks against state that may be incomplete or wrong. The same project.copy build, in warning mode, twice:

Output (Gradle 9.8.1, Kotlin DSL; the Groovy DSL printed the same)
$ ./gradlew --configuration-cache --configuration-cache-problems=warn exportStations
Calculating task graph as no cached configuration is available for tasks: exportStations
> Task :exportStations
1 problem was found storing the configuration cache.
- Task `:exportStations` of type `org.gradle.api.DefaultTask`: invocation of 'Task.project' at execution time is unsupported with the configuration cache.
  See https://docs.gradle.org/9.8.1/userguide/configuration_cache_requirements.html#config_cache:requirements:use_project_during_execution
See the complete report at file://<work>/kts/build/reports/configuration-cache/bjhs195hmsrwwpqdysqazbmom/1a10tq7yrbuvi2z8o8c24wlno/configuration-cache-report.html
[Incubating] Problems report is available at: file://<work>/kts/build/reports/problems/problems-report.html
BUILD SUCCESSFUL in 1s
Configuration cache entry stored with 1 problem.

$ ./gradlew --configuration-cache --configuration-cache-problems=warn exportStations
Reusing configuration cache.
> Task :exportStations UP-TO-DATE
[Incubating] Problems report is available at: file://<work>/kts/build/reports/problems/problems-report.html
BUILD SUCCESSFUL in 1s
Configuration cache entry reused.
stations.csv

The first run succeeds ("Configuration cache entry stored with 1 problem"); the second reuses the entry, and this time the task is up to date. Nothing in the second run says that the entry carries a problem. This is why the manual prefers to mark the offending tasks as incompatible and keep problems=fail, and why Gradle 9.0.0 added that incompatible tasks prevent cache hits in warning mode, "ensuring correctness". Since Gradle 9.7.0 the same flag no longer downgrades Isolated Projects violations to warnings.

Declaring a task incompatible

Sometimes a task cannot be fixed now, for example because it comes from a plugin. notCompatibleWithConfigurationCache(reason) tells Gradle so. The problems of that task no longer fail the build; if the task runs, Gradle discards the entry at the end of the build, whatever the problems mode, so that the broken state is never reused. The experiment has one ordinary task and one that uses project at execution time and is marked:

Kotlin DSL: build.gradle.kts
tasks.register("legacyReport") {
    notCompatibleWithConfigurationCache("prints the project name at execution time")
    doLast {
        println("project ${project.name}")
    }
}

tasks.register("hello") {
    doLast {
        println("hello from tidepool")
    }
}
Groovy DSL: build.gradle
tasks.register('legacyReport') {
    notCompatibleWithConfigurationCache('prints the project name at execution time')
    doLast {
        println "project ${project.name}"
    }
}

tasks.register('hello') {
    doLast {
        println 'hello from tidepool'
    }
}
Output (Gradle 9.8.1, Kotlin DSL; the Groovy DSL printed the same)
$ ./gradlew --configuration-cache hello
Calculating task graph as no cached configuration is available for tasks: hello
> Task :hello
hello from tidepool
BUILD SUCCESSFUL in 1s
Configuration cache entry stored.

$ ./gradlew --configuration-cache legacyReport
Calculating task graph as no cached configuration is available for tasks: legacyReport
> Task :legacyReport
project tidepool
1 problem was found storing the configuration cache.
- Task `:legacyReport` of type `org.gradle.api.DefaultTask`: invocation of 'Task.project' at execution time is unsupported with the configuration cache.
  See https://docs.gradle.org/9.8.1/userguide/configuration_cache_requirements.html#config_cache:requirements:use_project_during_execution
See the complete report at file://<work>/kts/build/reports/configuration-cache/6g6c1vwkgikc82aqhmue4kmbu/cqx0btluum1b56q0j4sag2njz/configuration-cache-report.html
BUILD SUCCESSFUL in 1s
Configuration cache entry discarded with 1 problem.

$ ./gradlew --configuration-cache legacyReport
Calculating task graph as no cached configuration is available for tasks: legacyReport
> Task :legacyReport
project tidepool
1 problem was found storing the configuration cache.
- Task `:legacyReport` of type `org.gradle.api.DefaultTask`: invocation of 'Task.project' at execution time is unsupported with the configuration cache.
  See https://docs.gradle.org/9.8.1/userguide/configuration_cache_requirements.html#config_cache:requirements:use_project_during_execution
See the complete report at file://<work>/kts/build/reports/configuration-cache/6g6c1vwkgikc82aqhmue4kmbu/cqx0btluum1b56q0j4sag2njz/configuration-cache-report.html
BUILD SUCCESSFUL in 1s
Configuration cache entry discarded with 1 problem.

$ ./gradlew --configuration-cache hello
Reusing configuration cache.
> Task :hello
hello from tidepool
BUILD SUCCESSFUL in 1s
Configuration cache entry reused.

Running hello stores an entry. Running legacyReport succeeds, which the unmarked task could not do, but the problem is still listed and the entry is "discarded with 1 problem", so every run of legacyReport calculates the task graph again, while hello keeps reusing its own entry. Since Gradle 9.0.0 the cache also falls back to a normal, uncached build on its own when the build uses a feature that it cannot support (the release notes name the Maven Publish and Ivy Publish plugins with limited support, the Eclipse and IDEA plugins and source dependencies), and the report says why.

Diagnosing obscure failures

Gradle writes entries with only minimal integrity checks, for speed. When loading an entry fails with an obscure error such as "Index 4 out of bounds for length 3", Gradle 8.14 added the property org.gradle.configuration-cache.integrity-check=true, which gives a precise error naming the field and the task whose state could not be read back. It makes entries bigger and slower, so it is for troubleshooting only (not run here). Typical causes are classes that implement Serializable with a writeObject that does not match readObject; Gradle supports a subset of Java serialization for them, with the limitations listed in the status page.

Parallel task execution

When the configuration cache is on, the tasks of one invocation run in parallel when they are independent, even inside a single project. Without it, Gradle’s --parallel runs tasks of different projects at once, but tasks of one project cannot, because they share the Project object and contend for project-level locks. With the cache, every task works on its own deserialized copy of its state, so it needs no project lock. Two tasks that each sleep for two seconds and print the clock make it visible:

Kotlin DSL: build.gradle.kts
import java.time.LocalTime
import java.time.format.DateTimeFormatter

abstract class Nap : DefaultTask() {
    @TaskAction
    fun nap() {
        val clock = DateTimeFormatter.ofPattern("HH:mm:ss")
        println("$name started ${LocalTime.now().format(clock)}")
        Thread.sleep(2000)
        println("$name finished ${LocalTime.now().format(clock)}")
    }
}

tasks.register<Nap>("napA")
tasks.register<Nap>("napB")
Groovy DSL: build.gradle
import java.time.LocalTime
import java.time.format.DateTimeFormatter

abstract class Nap extends DefaultTask {
    @TaskAction
    void nap() {
        def clock = DateTimeFormatter.ofPattern('HH:mm:ss')
        println "$name started ${LocalTime.now().format(clock)}"
        Thread.sleep(2000)
        println "$name finished ${LocalTime.now().format(clock)}"
    }
}

tasks.register('napA', Nap)
tasks.register('napB', Nap)
Output (Gradle 9.8.1, Kotlin DSL; the Groovy DSL showed the same overlap)
$ ./gradlew napA napB
> Task :napA
napA started 21:20:17
napA finished 21:20:19
> Task :napB
napB started 21:20:19
napB finished 21:20:21
BUILD SUCCESSFUL in 5s

$ ./gradlew --configuration-cache napA napB
Calculating task graph as no cached configuration is available for tasks: napA napB
> Task :napA
napA started 21:20:22
napA finished 21:20:24
> Task :napB
napB started 21:20:22
napB finished 21:20:24
BUILD SUCCESSFUL in 2s
Configuration cache entry stored.

$ ./gradlew --configuration-cache --no-parallel napA napB
Reusing configuration cache.
> Task :napA
napA started 21:20:25
napA finished 21:20:27
> Task :napB
napB started 21:20:25
napB finished 21:20:27
BUILD SUCCESSFUL in 2s
Configuration cache entry reused.

$ ./gradlew --parallel napA napB
> Task :napA
napA started 21:30:39
napA finished 21:30:41
> Task :napB
napB started 21:30:41
napB finished 21:30:43
BUILD SUCCESSFUL in 4s

Without the cache the two tasks ran one after the other, and took 5 seconds in all (4 seconds of sleeping, plus the start). With the cache, on the miss, they overlapped (both started at 21:20:22), and the whole build took 2 seconds. --parallel without the cache did not help: the last listing shows the tasks still running one after the other, because they belong to the same project. The third run of the first listing shows that --no-parallel has no effect once the cache is on: the tasks again ran together, and the manual says so (parallel task execution cannot be disabled with the cache active, neither with --no-parallel nor with org.gradle.parallel=false). The cost of this guarantee is that a build that relies on tasks running in a particular order must say so, with inputs and outputs, mustRunAfter or a shared build service that coordinates access to a resource; the observed order of unrelated tasks, as the listings show, is not stable.

Read-only mode for CI

On a CI agent that starts from a clean checkout, writing an entry on a miss is wasted effort: nothing keeps .gradle/configuration-cache for the next build. Gradle 9.1.0 added a read-only mode, still incubating in 9.8.1, in which Gradle uses an entry if there is a valid one and otherwise runs the build without serializing anything, which is as fast as a build can be on a miss. The experiment runs it on an empty cache, writes an entry with a normal run, uses it read-only, changes the build script and runs read-only twice:

Output (Gradle 9.8.1, Kotlin DSL; the option is not DSL-specific)
$ ./gradlew --configuration-cache -Dorg.gradle.configuration-cache.read-only=true hello
Calculating task graph as no cached configuration is available for tasks: hello
Read-only Configuration Cache is an incubating feature.
> Task :hello
hello from tidepool
BUILD SUCCESSFUL in 2s
Configuration cache disabled as cache is in read-only mode.
configuration-cache.lock
gc.properties

$ ./gradlew --configuration-cache hello
Calculating task graph as no cached configuration is available for tasks: hello
> Task :hello
hello from tidepool
BUILD SUCCESSFUL in 1s
Configuration cache entry stored.

$ ./gradlew --configuration-cache -Dorg.gradle.configuration-cache.read-only=true hello
Reusing configuration cache.
Read-only Configuration Cache is an incubating feature.
> Task :hello
hello from tidepool
BUILD SUCCESSFUL in 1s
Configuration cache entry reused.

$ echo '// changed' >> build.gradle.kts

$ ./gradlew --configuration-cache -Dorg.gradle.configuration-cache.read-only=true hello
Calculating task graph as configuration cache cannot be reused because file 'build.gradle.kts' has changed.
Read-only Configuration Cache is an incubating feature.
> Task :hello
hello from tidepool
BUILD SUCCESSFUL in 2s
Configuration cache disabled as cache is in read-only mode.

$ ./gradlew --configuration-cache -Dorg.gradle.configuration-cache.read-only=true hello
Calculating task graph as configuration cache cannot be reused because file 'build.gradle.kts' has changed.
Read-only Configuration Cache is an incubating feature.
> Task :hello
hello from tidepool
BUILD SUCCESSFUL in 1s
Configuration cache disabled as cache is in read-only mode.

The first run found no entry, printed "Configuration cache disabled as cache is in read-only mode" and stored nothing (the listing of the cache directory afterwards showed only its lock and bookkeeping files). The read-only run in the middle reused the entry written by the normal run. After the script was changed, the read-only runs recalculated the graph both times and still did not write ("cache is in read-only mode"). The manual’s warning is the important caveat: because a miss skips serialization, a read-only run does not detect configuration cache problems of the build logic. It is not a way to check that a build is compatible; use the default mode for that, for example in a pull request job, and the read-only mode in the jobs that only consume an entry that was restored from somewhere. Storing entries in parallel with org.gradle.configuration-cache.parallel=true is also incubating; its benefit is largest with org.gradle.parallel=true as well (neither was run here).

Security

The entry contains the complete state of every scheduled task, including private fields, so a password, token or key that a task field holds is stored too. Gradle encrypts the entry and the fingerprint with a machine-specific key that it creates as needed and keeps in the Gradle User Home (in this run, caches/9.8.1/cc-keystore/gradle.keystore under the Gradle User Home); the encryption has been on since Gradle 8.1. It does not help if an attacker can read both the project’s .gradle/configuration-cache directory and the key, so the manual’s advice is:

  • Keep credentials in GRADLE_USER_HOME/gradle.properties: that file is not serialized, only its fingerprint is recorded. Use the credentials API of repositories (see Gradle Repositories & Content Filtering) so that Gradle looks the values up when it needs them.

  • Do not pass secrets through system properties or environment variables read at configuration time: they go into the fingerprint. Wire providers.environmentVariable() into the task instead, so that the value is read at execution time.

  • Mark fields that are not needed after loading as transient; there is no Gradle annotation to exclude one field from the entry.

  • Do not let .gradle/configuration-cache be committed or published, and restrict access to it and to the key on shared and CI machines. The GRADLE_ENCRYPTION_KEY environment variable sets the key explicitly where a per-machine keystore is not suitable, for example when the Gradle User Home is shared between machines.

Status

The configuration cache has been through four stages: experimental in Gradle 6.6 (2020-08-10), stable in 8.1 (2023-04-12, when the properties lost their unsafe prefix and the cache became encrypted), the preferred mode of execution in 9.0.0 (2025-07-31; still off by default, with a hint and a graceful fallback), and, announced for the future, on by default in Gradle 10. The manual says the same in its own words: "This feature will be enabled by default in Gradle 10", with these limitations today: some core plugins and features have partial or pending support, builds and their plugins may need changes, IDE imports and syncs do not use the cache, and support for CI is still evolving.

Plugin support, as listed on the status page for 9.8.1: the core plugins for JVM languages and frameworks (Java, Java Library, Java Platform, Groovy, Scala, ANTLR), the native ones, Application, WAR, EAR, Distribution, code analysis (Checkstyle, CodeNarc, JaCoCo, PMD), the aggregation plugins, Signing, Build Init and the plugin-development plugins are all supported. Maven Publish and Ivy Publish are marked as partially supported: some of their features disable the cache (see Publishing with Gradle). Of the community plugins, the manual names the Android Gradle Plugin and the Kotlin Gradle Plugin as supported, and points to a Gradle issue that tracks the rest.

What is not yet implemented, per the same page:

  • Sharing the cache. It is stored locally only: hot and cold daemons on the same machine can reuse it, but developers and CI machines cannot share entries. This is why the CI recommendation is the read-only mode above, and why, until sharing exists, the build cache is what carries work between machines.

  • Parallel storing independent of --parallel.

  • Only part of Java serialization: classes that implement Externalizable, or define writeObject and/or readObject, writeReplace or readResolve; serialPersistentFields and several stream methods are not supported.

  • Calling top-level methods and variables of a build script at execution time (the experiment above).

  • A build service, or a provider derived from one, as a ValueSource parameter that is read at configuration time; arbitrary providers as build event listeners.

  • Selective exclusion of sensitive values from the entry.

Isolated Projects

The configuration cache skips configuration when nothing changed. Everything else still configures all the projects, one after another: a change to any build script, the first build on a clean checkout, and every IDE sync. In a build with hundreds of projects that is minutes. Isolated Projects attacks that cost differently: it forbids a project’s build logic from reaching into another project’s mutable state, and as a result Gradle can configure the projects in parallel, and, in the future, cache and re-run them individually. Isolated Projects reached Gradle 9.7.0 (2026-08-06) as an incubating feature (it was experimental before, with property names starting with org.gradle.unsafe), so its API and behaviour may still change and the release notes mark the changes. The 9.7.0 release notes say it is not enabled by default and not yet recommended for production, and that compatibility with the configuration cache is a prerequisite.

Enabling it

The switch is a flag or a property, and it implies the configuration cache:

Setting Effect

--isolated-projects

Enable for this build.

org.gradle.isolated-projects=true

Enable in gradle.properties. This is required for IDE sync, which does not read the command line.

-Dorg.gradle.isolated-projects.diagnostics=true

Diagnostics mode: configure projects one after another and keep going after a violation, to list as many as possible.

-Dorg.gradle.isolated-projects.dangerously-ignore-problems=true

Report violations but do not fail the build (a measuring aid, never for producing artifacts).

org.gradle.unsafe.isolated-projects and the two .unsafe. variants of the others

Legacy names, deprecated in 9.7.0 but still accepted; if both names are set, an explicit false in either disables the feature.

What it speeds up

Isolated Projects changes one phase. Initialization (init scripts and the settings file) stays sequential. Configuration of the projects runs in parallel, including the compilation of their build scripts. Execution is not affected, because with the configuration cache tasks already run in parallel. On a hit, the whole initialization and configuration are skipped, with or without Isolated Projects. The manual points out that the gain is bounded by the share of configuration in the total build time: on a machine with N cores the build does not become N times faster, and if configuration is 40 percent of the build the ceiling is 40 percent. Two more limits apply today: all projects are always configured (there is no project configuration avoidance, and configuration on demand has no effect), and a subproject cannot start until its parents are done, so siblings are the unit of parallelism. The degree of parallelism is org.gradle.workers.max (the number of cores by default). For IDE sync, parallel model building is switched on automatically with Isolated Projects in Android Studio Electric Eel or later and IntelliJ IDEA 2026.1.1 or later (not run here).

The experiment below uses a build of four sibling projects whose build scripts only sleep for 1.5 seconds each, to stand in for expensive configuration. The settings file is include("alpha", "beta", "gamma", "delta") (include 'alpha', 'beta', 'gamma', 'delta' in Groovy) and each build.gradle.kts / build.gradle is the single line Thread.sleep(1500). The task is help, which does nothing; the times are the times of configuration:

Output (Gradle 9.8.1, Kotlin DSL; the Groovy DSL took 7 s, 7 s, 798 ms, 2 s and 1 s)
$ ./gradlew help --no-configuration-cache
BUILD SUCCESSFUL in 6s

$ ./gradlew help --configuration-cache
Calculating task graph as no cached configuration is available for tasks: help
BUILD SUCCESSFUL in 7s
Configuration cache entry stored.

$ ./gradlew help --configuration-cache
Reusing configuration cache.
BUILD SUCCESSFUL in 798ms
Configuration cache entry reused.

$ ./gradlew help --isolated-projects
Calculating task graph as no cached configuration is available for tasks: help
BUILD SUCCESSFUL in 2s
Configuration cache entry stored.

$ ./gradlew help --isolated-projects
Reusing configuration cache.
BUILD SUCCESSFUL in 736ms
Configuration cache entry reused.

Without any cache, four projects configure one after another: 6 seconds. With the configuration cache a miss is no faster, here 7 seconds (the extra second is presumably the cost of writing the entry), and a hit takes 798 milliseconds. With Isolated Projects the miss takes 2 seconds, because the four scripts ran at the same time, and a hit is as fast as with the plain cache. The numbers are specific to this artificial build, on a machine with several cores; the point is the shape, not the ratio.

The constraints

During configuration Gradle does many small things (evaluate the settings, configure each project), and each of them mutates live objects such as Project, Settings and Gradle. Isolated Projects does not change how a project is configured: plugins still mutate the project they are applied to. It adds boundaries so that one unit cannot observe or change the mutable state of another:

Kind What is not allowed

Cross-project access

A project reading or changing mutable state of another project: description, group, version, buildDir; its tasks, dependencies, configurations, artifacts, repositories, plugins, extensions and ext; its layout, providers and objects. Allowed: the immutable data fixed during settings evaluation (name, path, depth, projectDir, buildFile, rootDir) and navigating the project tree (rootProject, parent, project(...), subprojects, allprojects), but then only to read that data. group, version, buildDir and layout are mutable.

Project-to-build access

A project using mutable state of its Gradle object: registering listeners or evaluation callbacks (addBuildListener, beforeProject, afterProject), applying plugins, using extensions. Allowed: gradleVersion, gradleUserHomeDir, buildPath, includedBuilds, providers, startParameter. Registering a build service with registerIfAbsent is safe; querying the set of registrations is limited to findByName.

Cross-build access

Using gradle.parent from an included build. Not fully enforced yet; avoid it.

Build-to-project access

Settings or init-script callbacks that capture mutable build state and run during the configuration of projects, possibly in parallel. Not fully enforced yet; use the state-isolating callbacks below.

A violation is detected when it happens, and then the build fails, since its results could not be trusted. Here is a build with two of the most common ones. The root build script configures every project with allprojects, and a subproject reads the version of the root project:

Kotlin DSL: build.gradle.kts
version = "1.0.0"

allprojects {
    group = "io.tidepool"
}
Kotlin DSL: tidepool-engine/build.gradle.kts (added at the end of the file)
val rootVersion = rootProject.version
tasks.register("showVersion") {
    doLast {
        println("root version is $rootVersion")
    }
}
Groovy DSL: build.gradle
version = '1.0.0'

allprojects {
    group = 'io.tidepool'
}
Groovy DSL: tidepool-engine/build.gradle (added at the end of the file)
def rootVersion = rootProject.version
tasks.register('showVersion') {
    doLast {
        println "root version is $rootVersion"
    }
}

By default the build fails at the first violation:

Output (Gradle 9.8.1, Kotlin DSL)
$ ./gradlew help --isolated-projects
Isolated Projects is an incubating feature.
Calculating task graph as no cached configuration is available for tasks: help
1 problem was found storing the configuration cache.
- Build file 'build.gradle.kts': Project ':' cannot access 'Project.group' functionality on subprojects via 'allprojects'
See the complete report at file://<work>/kts-ip/build/reports/configuration-cache/ebpp96lobvg77zxsz0aqftl72/51qu0vujhgwd7g58ngkwm521o/configuration-cache-report.html
FAILURE: Build failed with an exception.
* Where:
Build file '<work>/kts-ip/build.gradle.kts' line: 4
* What went wrong:
Project ':' cannot access 'Project.group' functionality on subprojects via 'allprojects'
BUILD FAILED in 1s
Configuration cache entry discarded with 1 problem.

The Diagnostics mode (introduced in Gradle 9.6.0, according to the manual’s change log) runs the configuration of the projects sequentially so that it can continue after a violation, and lists them all, with the file and, in the Groovy DSL, the line:

Output (Gradle 9.8.1, Kotlin DSL: -Dorg.gradle.isolated-projects.diagnostics=true)
$ ./gradlew help --isolated-projects -Dorg.gradle.isolated-projects.diagnostics=true
Isolated Projects is an incubating feature.
Calculating task graph as no cached configuration is available for tasks: help
> Task :help
FAILURE: Build failed with an exception.
* What went wrong:
Configuration cache problems found in this build.
4 problems were found storing the configuration cache, 2 of which seem unique.
- Build file 'build.gradle.kts': Project ':' cannot access 'Project.group' functionality on subprojects via 'allprojects'
- Build file 'tidepool-engine/build.gradle.kts': Project ':tidepool-engine' cannot access 'Project.version' functionality on another project ':'
See the complete report at file://<work>/kts-ip/build/reports/configuration-cache/ebpp96lobvg77zxsz0aqftl72/bsrs2w6bei2mpoi8oc8c5fvmf/configuration-cache-report.html
BUILD FAILED in 1s
Configuration cache entry discarded with 4 problems.
Output (Gradle 9.8.1, Groovy DSL, the part that differs)
FAILURE: Build failed with an exception.
* Where:
Build file '<work>/groovy-ip/build.gradle' line: 4
* What went wrong:
Configuration cache problems found in this build.
4 problems were found storing the configuration cache, 2 of which seem unique.
- Build file 'build.gradle': line 4: Project ':' cannot access 'Project.group' functionality on subprojects via 'allprojects'
- Build file 'tidepool-engine/build.gradle': line 16: Project ':tidepool-engine' cannot access 'Project.version' functionality on another project ':'

Two problems are unique; the build reports four because the root-project allprojects violation occurs once per subproject. The same problems are in the configuration cache report, which is where the manual sends you to find the violating build logic. The Diagnostics mode is slower on purpose (no parallel configuration and no parallel storing, no model reuse) and is for migration, not for committing to the repository. A clean run in this mode does not prove the build compatible, since violations can hide behind lazy task configuration that the chosen tasks did not realize.

Behavior changes

Isolated Projects changes the behaviour of a few APIs, because it validates what would have given a different result. The one that matters is that a project no longer finds properties and methods of its parent projects implicitly: a reference to a property that only the root project defines does not fall through to the root. Gradle 9.6.0 deprecated that lookup also without Isolated Projects, and it is announced as removed in Gradle 10. Lenient lookups such as Project.findProperty() do not fail, they return a different value, so the manual recommends fixing the deprecation warnings first, and offers a feature preview flag, enableFeaturePreview("NO_IMPLICIT_LOOKUP_IN_PARENT_PROJECTS") in the settings file, that gives the same behaviour in all modes (not run here). In the same way, the properties report of a subproject no longer lists the properties it would have inherited, and Project.getProperties() is a violation.

Migrating a build

The manual’s path, in the order to follow it:

  1. Upgrade Gradle to the latest release (a milestone or nightly if you want the freshest fixes while the feature is incubating), update the plugins, and use a recent IDE (IntelliJ IDEA 2026.1.1 or later is recommended).

  2. Make the build compatible with the configuration cache first. Isolated Projects is built on it.

  3. Address the deprecations that concern the behaviour changes above.

  4. Run ./gradlew help --isolated-projects -Dorg.gradle.isolated-projects.diagnostics=true and read the violations in the report. They are the ones that appear in virtually any workflow, so they come first.

  5. Report violations that come from community plugins to their maintainers, and fix your own build logic. An incompatible plugin takes away the benefit.

  6. Repeat for IDE sync (both properties in gradle.properties, because sync does not read the command line), then for the other workflows: tests, checks and so on, since lazy configuration hides violations until a task is realized.

  7. Switch the Diagnostics mode off once the violations are gone.

If you want to estimate the gain before fixing everything, -Dorg.gradle.isolated-projects.dangerously-ignore-problems=true reports the violations but does not fail the build. The output of the run below (the violating rootProject.version was put back into tidepool-engine) shows what Gradle thinks of it: the banner is printed at the start and the end of the build, and the entry is still discarded because of the violation, so no later run can reuse it:

Output (Gradle 9.8.1, Kotlin DSL)
$ ./gradlew --isolated-projects -Dorg.gradle.isolated-projects.dangerously-ignore-problems=true :tidepool-engine:showVersion
Isolated Projects is an incubating feature.
Calculating task graph as no cached configuration is available for tasks: :tidepool-engine:showVersion
==================================================================================================
Isolated Projects dangerously-ignore-problems is ENABLED.
Isolated Projects violations are being ignored.
Build outputs may be incorrect and the build may crash unexpectedly.
Use this only to evaluate performance.
Do not use this to produce artifacts.
See https://docs.gradle.org/9.8.1/userguide/isolated_projects.html#sec:dangerously_ignore_problems
==================================================================================================
> Task :tidepool-engine:showVersion
1.1.0:tidepool-engine
1 problem was found storing the configuration cache.
- Build file 'tidepool-engine/build.gradle.kts': Project ':tidepool-engine' cannot access 'Project.version' functionality on another project ':'
See the complete report at file://<work>/kts-ip/build/reports/configuration-cache/4ac97srdmwst7p8roqxgwvfkw/999wtmet3ew9bx8w10c91c5yg/configuration-cache-report.html
BUILD SUCCESSFUL in 1s
Configuration cache entry discarded with 1 problem.
==================================================================================================
Isolated Projects dangerously-ignore-problems is ENABLED.
Isolated Projects violations are being ignored.
Build outputs may be incorrect and the build may crash unexpectedly.
Use this only to evaluate performance.
Do not use this to produce artifacts.
See https://docs.gradle.org/9.8.1/userguide/isolated_projects.html#sec:dangerously_ignore_problems
==================================================================================================

The manual warns that the build outputs may be incorrect and the build may crash with concurrency errors; combine it with the Diagnostics mode to avoid the latter.

Making build logic compatible

The strategies of the manual, in order of preference:

  1. Convention plugins. A project applies the plugin to itself, so nothing configures another project. This is also the recommended way to share build logic for other reasons; see Convention Plugins & Sharing Build Logic. Plugins compose, so reuse does not cost compatibility.

  2. State-isolating lifecycle callbacks. When configuring many projects from one place is really wanted, gradle.lifecycle.beforeProject { } and afterProject { } (the GradleLifecycle API) register actions that run in a context private to each project, which is what the configuration cache does for tasks. They must be registered from the settings file or a settings plugin, not from a project.

  3. IsolatedProject instead of Project. Where a project must read something about another one, isolated (for example isolated.rootProject.projectDirectory) gives the immutable data.

  4. Guard incompatible code as a last resort.

The experiment replaces the violating build with the second and third strategies. The allprojects block and the root build script are gone; the settings file sets the group and version of every project from a version.txt in the root, and tidepool-engine reads its own coordinates:

Kotlin DSL: settings.gradle.kts (appended)
gradle.lifecycle.beforeProject {
    group = "io.tidepool"
    version = providers.fileContents(
        isolated.rootProject.projectDirectory.file("version.txt")
    ).asText.get().trim()
}
Groovy DSL: settings.gradle (appended)
gradle.lifecycle.beforeProject {
    group = 'io.tidepool'
    version = providers.fileContents(
        isolated.rootProject.projectDirectory.file('version.txt')
    ).asText.get().trim()
}
Kotlin DSL: tidepool-engine/build.gradle.kts (appended)
tasks.register("showVersion") {
    val coordinates = "${project.group}:${project.name}:${project.version}"
    doLast {
        println(coordinates)
    }
}
Groovy DSL: tidepool-engine/build.gradle (appended)
tasks.register('showVersion') {
    def coordinates = "${project.group}:${project.name}:${project.version}"
    doLast {
        println coordinates
    }
}

version.txt holds 1.0.0. The first run stores an entry, the second reuses it; then version.txt is changed to 1.1.0 and the third run calculates the graph again, because the file read by providers.fileContents is a configuration input:

Output (Gradle 9.8.1, Kotlin DSL; the Groovy DSL printed the same for the first two runs)
$ ./gradlew --isolated-projects :tidepool-engine:showVersion
Isolated Projects is an incubating feature.
Calculating task graph as no cached configuration is available for tasks: :tidepool-engine:showVersion
> Task :tidepool-engine:showVersion
io.tidepool:tidepool-engine:1.0.0
BUILD SUCCESSFUL in 1s
Configuration cache entry stored.

$ ./gradlew --isolated-projects :tidepool-engine:showVersion
Isolated Projects is an incubating feature.
Reusing configuration cache.
> Task :tidepool-engine:showVersion
io.tidepool:tidepool-engine:1.0.0
BUILD SUCCESSFUL in 829ms
Configuration cache entry reused.

$ echo 1.1.0 > version.txt

$ ./gradlew --isolated-projects :tidepool-engine:showVersion
Isolated Projects is an incubating feature.
Calculating task graph as configuration cache cannot be reused because file 'version.txt' has changed.
> Task :tidepool-engine:showVersion
io.tidepool:tidepool-engine:1.1.0
BUILD SUCCESSFUL in 670ms
Configuration cache entry stored.

A limitation applies to the callbacks: a beforeProject callback only registers an action and does not resolve plugins, so it can apply a plugin that is already on the project’s classpath, such as the core base plugin used by the manual, but not one that is resolved lazily from a repository or from an included build registered with pluginManagement.includeBuild. The manual’s recommended workaround is to register the callback from a settings convention plugin, where the included build’s classpath is available; the other is to declare the plugin in the settings plugins { } block with apply false, which forces its resolution. Plugins from buildSrc are not affected, but the manual advises against moving logic into buildSrc just to avoid this. Plugin authors can declare in their descriptor that the plugin is compatible, with the compatibility { features { isolatedProjects = true } } block of the Plugin Publish plugin (Gradle 9.8.0; see Testing & Publishing Gradle Plugins).

Isolated Projects and the other features

  • Configuration cache. A prerequisite, and a base: violations are reported as configuration cache problems, and --configuration-cache-problems=warn no longer downgrades them (9.7.0). Storing the entry in parallel is the same mechanism as the parallel storing of the cache, now made safe by the constraints.

  • --parallel. It concerns the execution of tasks of different projects and is independent of project configuration.

  • Configuration on demand has no effect, since all projects are configured; see The Gradle Build Lifecycle.

  • Included builds. A change to an included build invalidates all cached results, even unrelated ones (a current limitation); see Gradle Composite Builds.

  • Build Scan and profile reports are the way to measure the configuration phase before and after (see Performance Profiling & Build Scans).

Release history

Release (date) Change

6.6 (2020-08-10)

The configuration cache appears, experimental.

8.1 (2023-04-12)

Stable. Entries are encrypted, dependency verification and file-system Ivy and Maven repositories are supported, the Ivy Publish and Signing plugins become compatible, and Dataflow Actions are introduced as the replacement for buildFinished. The org.gradle.unsafe.configuration-cache properties are renamed org.gradle.configuration-cache.

8.4

Undeclared inputs read while the task graph is serialized are tracked and invalidate the entry.

8.14 (2025-04-25)

The integrity check mode, org.gradle.configuration-cache.integrity-check.

9.0.0 (2025-07-31)

The preferred mode of execution: the hint at the end of a compatible build, graceful fallback for unsupported features, immediate abort when a problem occurs while a task runs. Still off by default.

9.1.0 (2025-09-18)

The read-only mode (incubating) for CI; a changed -P property only invalidates the entry if configuration read it.

9.6.0 (2026-06-18)

The implicit lookup of properties in parent projects is deprecated; the Isolated Projects Diagnostics mode appears.

9.7.0 (2026-08-06)

Isolated Projects incubating, the org.gradle.isolated-projects property names, --isolated-projects, the dangerously-ignore mode; ResolutionResult as a task input; third-party Java agents with TestKit.

9.8.0 (2026-09-24)

Task logging listeners registered during configuration are stored and re-registered on a hit; plugin authors can declare Isolated Projects compatibility on the Plugin Portal.

9.8.1 (2026-10-07)

The release this page was written against.

Gradle 10 (announced)

The configuration cache on by default and the implicit parent-project lookup removed. Stated as announced, not released.

Gradle 8.14 and Gradle 9.8.1

Gradle 8.14.x was not available here; the table is from the release notes and the upgrade guides (not run here):

Gradle 8.14 Gradle 9.8.1 (run here)

Status

Stable since 8.1; off by default; no hint

Preferred mode; off by default; hint at the end of a compatible build; on by default announced for Gradle 10

Unsupported features

Fail the build with a problem

Graceful fallback to an uncached build, with the reason in the report

Project at execution time

Deprecated in 8 (the upgrade guide), Project.exec and javaexec still available

A configuration cache problem; Project.exec and javaexec removed in 9.0.0

Read-only mode

Not available

Available (incubating), 9.1.0

-P properties

Any change invalidates the entry

Only the properties that configuration read

Integrity check

Added in 8.14

Available

Isolated Projects

Experimental; org.gradle.unsafe.isolated-projects=true

Incubating since 9.7.0; org.gradle.isolated-projects=true and --isolated-projects

Running Gradle

Java 8 or later

Java 17 or later (the build still compiles for older Java through toolchains)

The same idea in Ant and Maven

Gradle Maven Ant

Configuration cache: the result of configuration is stored and reused

Nothing to cache in the same sense. Maven reads and interprets the POMs of the reactor on every run (see Maven Multi-Module Builds & the Reactor); the daemon mvnd keeps a warm JVM instead (see Maven Build Performance).

Nothing. Ant parses the build file on every run (see Ant Targets & Extension Points).

Parallel execution of independent tasks inside a project, with the cache

-T runs modules in parallel; goals inside one module run in order (Maven 4 has a concurrent builder as well; both are in Maven Build Performance).

<parallel> and <subant> are explicit; independent targets are not run in parallel.

Isolated Projects: forbid cross-project mutable access so that projects can be configured in parallel

No equivalent; plugins and the reactor can read any module’s model.

No equivalent.

Requirements for build logic: no Project at execution time, declared inputs

Plugins are separate code, with parameters injected by Maven; a mojo does not see other modules' state except through the project model.

Tasks are Java classes; properties are global and mutable during the run.

For the other two tools this is where Gradle is different, rather than better: its model is richer and its configuration phase has a cost to match, which the cache pays back.

Where the books are outdated

The book Gradle in Action (Gradle 1.7) predates the cache by seven years. Its builds read the environment and the project freely at execution time, which was normal; the statement "no configuration cache; project used freely at execution" is the one this page replaces. Today the configuration cache has been stable since 8.1 and preferred since 9.0, Task.project at execution time is a problem (a deprecation in the 8.x upgrade guide), and the book’s idiom of cross-project configuration with allprojects and subprojects is what Isolated Projects forbids, in favour of convention plugins. See Gradle Versions, Compatibility & Release History for the other version changes.

Summary

  • The configuration cache stores the result of the configuration phase (the task graph, with the state of every task) in .gradle/configuration-cache and, if every recorded input is unchanged, loads it instead of configuring the build; on a hit the scripts and plugins do not run at all.

  • Switch it on with --configuration-cache or org.gradle.configuration-cache=true. It is stable since 8.1, the preferred mode since 9.0 and announced as the default for Gradle 10.

  • An entry is invalidated by changes to build scripts, gradle.properties, catalogs, init scripts, files read at configuration time, and the system properties, environment variables and -P properties that configuration read. Gradle names the reason. Read values lazily, with providers and ValueSource, to avoid needless invalidation.

  • Build logic must not use Project while a task runs, must not capture the Kotlin script object or call script methods from actions, must not start processes or register listeners at configuration time, and should resolve dependencies lazily: eager resolution at store time shows latent errors.

  • Problems fail the build and discard the entry; the HTML report explains them. --configuration-cache-problems=warn and notCompatibleWithConfigurationCache() are migration tools, not a way of life.

  • With the cache tasks run in parallel even inside one project, and --no-parallel does not change that. In CI, the read-only mode avoids the cost of writing an entry nobody will read, but does not detect problems.

  • Entries contain the state of all tasks, are encrypted with a key in the Gradle User Home, and are local only; keep secrets out of task fields and in GRADLE_USER_HOME/gradle.properties.

  • Isolated Projects (incubating since 9.7.0, --isolated-projects) forbids a project from touching another project’s mutable state so that projects configure in parallel. Migrate with the Diagnostics mode, convention plugins, gradle.lifecycle.beforeProject and IsolatedProject.