Gradle Dependency Locking, Verification & Caching

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

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

This page is written against Gradle 9.8.1 and was run on JDK 26 on 2026-10-16 (the daemon and the compiler both ran on JDK 26; the code is compiled with sourceCompatibility 21, and no toolchain was involved). Every example was run through the wrapper in a scratch copy of the tidepool project, once with Kotlin DSL build files and once with Groovy DSL build files. The scratch copies pointed their wrapper at a local copy of the same distribution archive; the properties file that a real project commits is

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

In the listings the project directory of the scratch copies is replaced by /work/tidepool (and by /work/consumer and /work/ro in the cache section); where a message contained something else that is not part of the lesson, it is marked as omitted. The examples that need a clean state ran with their own Gradle user home, so that nothing depended on, or changed, the cache of the machine. The dependencies come from Maven Central, so the version numbers that you see (for example commons-csv 1.14.1, the newest 1.x on the day of the run) are those of 2026-10-16. All examples are compatible with the configuration cache and were run with --configuration-cache, with these exceptions, each said where it happens: the task that resolves every configuration for locking (resolveAndLockAll, which is declared incompatible on purpose), the runs that demonstrate an error (the configuration cache report would hide the message), and the runs that demonstrate caching, where the effect of the configuration cache is explained separately. Features newer than Gradle 8.14 name their release; Gradle 8.14.x was not installed here, so statements about it come from the official documentation and the release notes and are labelled "not run here".

A build that downloads code from the internet has three questions to answer, and Gradle has one feature for each. Which versions will I get? A dynamic version such as 1.+ answers differently tomorrow than it does today; dependency locking records the answer in a file, so that it stays the same until you ask for a new one. Is this really the file that the author published? A repository, a mirror or a man in the middle can substitute another; dependency verification checks every downloaded byte against checksums and signatures that you committed. Do I have to download it again? Not if you already did; the dependency cache remembers artifacts and the results of resolution, and a handful of switches say how far it is trusted. The three work at different moments of the same resolution, as the figure shows.

flowchart TD decl["Declared dependency: org.apache.commons:commons-csv:1.+"] --> lock{"Lock state present for this configuration?"} lock -- "yes" --> pin["Locked version added as a strictly constraint"] lock -- "no" --> dyn["Newest 1.x from the repository listing, cached for 24 hours"] pin --> cache{"Module metadata and artifact in the dependency cache?"} dyn --> cache cache -- "no" --> dl["Download from a repository"] cache -- "yes" --> ver dl --> ver{"Checksum or trusted signature in verification-metadata.xml?"} ver -- "yes" --> use(["Artifact goes on the classpath"]) ver -- "no" --> fail(["Build fails: dependency verification"])

The three features answer different questions and do not replace each other. Locking does not check bytes, verification does not pin versions, and the cache does neither. The tool-neutral discussion of why a build should care is on Supply-Chain Security (which also summarises the Gradle verification flow in a few commands) and on Dependency Management Fundamentals; this page is the full reference for the Gradle side. The configurations and the declaration syntax are on Declaring Dependencies & Configurations; the repositories that the cache remembers are on Repositories & Content Filtering; the rich version declarations that locking turns into constraints are on Platforms, Constraints & Rich Versions.

The project used on this page

The examples use the three-module tidepool project: tidepool-model (no dependencies), tidepool-engine (reads the station CSV with Commons CSV) and tidepool-cli (a picocli command line). The repository is declared once in settings.gradle.kts, and the versions live in the version catalog (see Version Catalogs). Both are written with dynamic versions on purpose, because dynamic versions are what locking exists for: 1.+ means "the newest 1.x", and 4.7.+ "the newest 4.7.x".

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'
# gradle/libs.versions.toml
[versions]
commons-csv = "1.+"
picocli = "4.7.+"

[libraries]
commons-csv = { module = "org.apache.commons:commons-csv", version.ref = "commons-csv" }
picocli = { module = "info.picocli:picocli", version.ref = "picocli" }
Kotlin DSL
// tidepool-engine/build.gradle.kts
dependencies {
    api(project(":tidepool-model"))
    implementation(libs.commons.csv)
}

// tidepool-cli/build.gradle.kts
plugins {
    application
}

dependencies {
    implementation(project(":tidepool-engine"))
    implementation(libs.picocli)
}

application {
    mainClass = "io.tidepool.cli.Main"
}
Groovy DSL
// tidepool-engine/build.gradle
dependencies {
    api project(':tidepool-model')
    implementation libs.commons.csv
}

// tidepool-cli/build.gradle
plugins {
    id 'application'
}

dependencies {
    implementation project(':tidepool-engine')
    implementation libs.picocli
}

application {
    mainClass = 'io.tidepool.cli.Main'
}

tidepool-model/build.gradle(.kts) is empty; the shared part (the java-library plugin and the Java level) is applied from the root build file with subprojects { ... }, as in the root build below (a real project would use a convention plugin, see Convention Plugins & Sharing Build Logic). Without locking, the dependency report shows what the dynamic versions resolved to today:

compileClasspath - Compile classpath for source set 'main'.
+--- project ':tidepool-model'
\--- org.apache.commons:commons-csv:1.+ -> 1.14.1
     +--- commons-io:commons-io:2.20.0
     \--- commons-codec:commons-codec:1.19.0

(The report was produced with ./gradlew :tidepool-engine:dependencies --configuration compileClasspath; the --scan hint line at its end is omitted here and in the other dependencies listings.) Commons CSV 1.14.1 brings commons-io and commons-codec with it. The versions of those two were not written by anyone in the build; they come from the POM of the version that 1.+ selected. A different day, another repository mirror or a new release would change them, and nothing in the build would say so. That is the problem the next section solves.

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

Dependency locking

Dependency locking makes the result of resolution a file in the repository. The file lists the exact version of every module that the locked configuration resolved to, including the transitive ones, and from then on Gradle uses it to verify that the build still resolves the same set.

Turning locking on

Locking is enabled per configuration; once enabled, a lock state must exist (or be created, see below), and the build then fails if resolution no longer produces what the file says. For a few configurations the resolution strategy has the switch:

Kotlin DSL
configurations {
    compileClasspath {
        resolutionStrategy.activateDependencyLocking()
    }
}
Groovy DSL
configurations {
    compileClasspath {
        resolutionStrategy.activateDependencyLocking()
    }
}

Only resolvable configurations carry lock state; the call on a consumable or declarable one does nothing. A build that wants every project configuration locked says so once, with the dependencyLocking extension, typically for all projects:

Kotlin DSL
// build.gradle.kts (root)
allprojects {
    dependencyLocking {
        lockAllConfigurations()
    }

    tasks.register("resolveAndLockAll") {
        notCompatibleWithConfigurationCache("Resolves configurations at execution time")
        doFirst {
            require(gradle.startParameter.isWriteDependencyLocks) { "Run with --write-locks" }
        }
        doLast {
            configurations.filter { it.isCanBeResolved }.forEach { it.resolve() }
        }
    }
}

subprojects {
    apply(plugin = "java-library")

    extensions.configure<JavaPluginExtension> {
        sourceCompatibility = JavaVersion.VERSION_21
    }
}
Groovy DSL
// build.gradle (root)
allprojects {
    dependencyLocking {
        lockAllConfigurations()
    }

    tasks.register('resolveAndLockAll') {
        notCompatibleWithConfigurationCache('Resolves configurations at execution time')
        doFirst {
            if (!gradle.startParameter.writeDependencyLocks) {
                throw new GradleException('Run with --write-locks')
            }
        }
        doLast {
            configurations.matching { it.canBeResolved }.each { it.resolve() }
        }
    }
}

subprojects {
    apply plugin: 'java-library'

    java {
        sourceCompatibility = JavaVersion.VERSION_21
    }
}

lockAllConfigurations() locks the configurations of the project, not those of the build script (buildscript { configurations.classpath }) nor those of the settings; both have their own switch, shown in the section "The build script and the settings". The task resolveAndLockAll is the helper that the Gradle documentation describes for writing all the lock states in one run, and it is explained in the next section. In the Groovy build it uses matching and not findAll: on Gradle 9.8.1 the first version of this build file used configurations.findAll { it.canBeResolved }.each { ... }, and the build printed The DomainObjectCollection.findAll(Closure) method has been deprecated. This is scheduled to be removed in Gradle 10. with a link to the upgrade guide, because findAll on a domain object collection is going away; matching returns the live, filtered collection and has no such problem. (The Kotlin filter is the Kotlin standard library function on a collection and is not affected.)

Writing the lock state

Lock state is only written when you ask for it, with --write-locks, on a build that resolves the locked configurations:

./gradlew resolveAndLockAll --write-locks
> Task :resolveAndLockAll
> Task :tidepool-cli:resolveAndLockAll
> Task :tidepool-engine:resolveAndLockAll
> Task :tidepool-model:resolveAndLockAll
Persisted dependency lock state for Dependency Resolution Management
Persisted dependency lock state for project ':tidepool-cli'
Persisted dependency lock state for project ':tidepool-engine'
Persisted dependency lock state for project ':tidepool-model'

BUILD SUCCESSFUL in 4s
4 actionable tasks: 4 executed

(The line about starting a daemon that could not reuse others is omitted; the machine had several daemons of other builds.) Gradle writes one gradle.lockfile per project, next to its build file, and one settings-gradle.lockfile for the dependency resolution management of the settings (the version catalog is resolved there). The lock file of the engine:

# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
# To regenerate this file, run: ./gradlew :tidepool-engine:dependencies --write-locks
commons-codec:commons-codec:1.19.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-io:commons-io:2.20.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-csv:1.14.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=annotationProcessor,testAnnotationProcessor

and of the command line project, which shows how the same module can be locked in some configurations and absent from others (the engine is an implementation dependency, so commons-csv is not on the compile classpath of the CLI):

commons-codec:commons-codec:1.19.0=runtimeClasspath,testRuntimeClasspath
commons-io:commons-io:2.20.0=runtimeClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-csv:1.14.1=runtimeClasspath,testRuntimeClasspath
empty=annotationProcessor,testAnnotationProcessor

The format is simple on purpose: one group:name:version per line followed by the configurations that contain it, sorted alphabetically so that version control diffs are small, and a last empty= line for locked configurations that resolved to nothing. The file belongs in version control, and Gradle does not write it when the build fails, so a failed resolution cannot leave a half-written state behind. The settings lock file had only the line empty=incomingCatalogForLibs0 (the configuration that resolves the catalog libs, which had no external dependencies).

Two details of the command are easy to miss. ./gradlew dependencies --write-locks writes the state of the configurations that the dependencies report resolves, and in a multi-project build the unqualified dependencies task runs in only one project (the one in the current directory): in the tidepool build it printed No configurations for the root project, persisted only the settings lock state, and created no gradle.lockfile at all. The two ways around it are naming the projects (./gradlew :tidepool-engine:dependencies :tidepool-cli:dependencies --write-locks) or a task that resolves everything, which is what resolveAndLockAll does. It declares itself incompatible with the configuration cache because it filters and resolves configurations at execution time; that is why this one command ran without --configuration-cache. The second detail: with --write-locks, Gradle ignores existing lock state and writes what the current resolution produced, and it writes only for the configurations that were resolved in that build. After ./gradlew compileJava --write-locks the lock file of the engine listed only compileClasspath and annotationProcessor; the runtime and test configurations had no entry (and so, in the default mode, nothing locked them) until a build resolved them.

What a lock does during resolution

Gradle turns each locked entry into a strictly version constraint, which is the strongest of the rich version declarations (see Platforms, Constraints & Rich Versions). The dependency report shows them, marked (c) for constraint:

compileClasspath - Compile classpath for source set 'main'.
+--- project ':tidepool-model'
+--- org.apache.commons:commons-csv:1.+ -> 1.14.1
|    +--- commons-io:commons-io:2.20.0
|    \--- commons-codec:commons-codec:1.19.0
+--- org.apache.commons:commons-csv:{strictly 1.14.1} -> 1.14.1 (c)
+--- commons-io:commons-io:{strictly 2.20.0} -> 2.20.0 (c)
\--- commons-codec:commons-codec:{strictly 1.19.0} -> 1.19.0 (c)

(c) - A dependency constraint, not a dependency. The dependency affected by the constraint occurs elsewhere in the tree.

and dependencyInsight gives the reason in words:

org.apache.commons:commons-csv:1.14.1
  Variant compile:
    | Attribute Name                 | Provided | Requested    |
    |--------------------------------|----------|--------------|
    | org.gradle.status              | release  |              |
    | org.gradle.category            | library  | library      |
    | org.gradle.libraryelements     | jar      | classes      |
    | org.gradle.usage               | java-api | java-api     |
    | org.gradle.dependency.bundling |          | external     |
    | org.gradle.jvm.environment     |          | standard-jvm |
    | org.gradle.jvm.version         |          | 21           |
   Selection reasons:
      - By constraint: Dependency version enforced by Dependency Locking

(./gradlew :tidepool-engine:dependencyInsight --dependency commons-csv --configuration compileClasspath, first lines.) Because the lock is only a constraint, the ordinary rules of version conflict resolution apply to it, and they explain every failure below. For a transitive dependency the strict constraint pins the version, whatever the transitive path would have chosen. For a direct dependency, the declared version is a requirement and the lock a strict constraint: if the requirement is lower than or equal to the locked version (or a range or dynamic selector that the locked version satisfies), the locked version wins; if the requirement is higher than the locked version, the two conflict and resolution fails.

When the lock and the build disagree

With lock state present, resolution is validated against it in the default mode. The three ways it can go wrong are shown here, each with the real message. These runs are without --configuration-cache because, with it, Gradle reports the same failure inside a configuration cache serialization problem (error writing value of type DefaultResolvableConfiguration) that hides the message in a report.

A dependency is added and the lock was not updated. The engine gets a direct dependency on org.slf4j:slf4j-api:2.0.17 and nothing else changes:

> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tidepool-engine:compileJava FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':tidepool-engine:compileJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').
> Could not resolve all files for configuration ':tidepool-engine:compileClasspath'.
   > Resolved 'org.slf4j:slf4j-api:2.0.17' which is not part of the dependency lock state

A dependency is removed and the lock still lists it:

> Task :tidepool-engine:compileJava FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':tidepool-engine:compileJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').
> Could not resolve all files for configuration ':tidepool-engine:compileClasspath'.
   > Did not resolve 'org.slf4j:slf4j-api:2.0.17' which is part of the dependency lock state

A direct dependency asks for more than the lock allows. The catalog was first [1.10,1.12), which locked Commons CSV at 1.11.0, and was then changed to the fixed version 1.14.1:

* What went wrong:
Execution failed for task ':tidepool-engine:compileJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').
> Could not resolve all files for configuration ':tidepool-engine:compileClasspath'.
   > Could not resolve org.apache.commons:commons-csv:1.14.1.
     Required by:
         project ':tidepool-engine'
      > Cannot find a version of 'org.apache.commons:commons-csv' that satisfies the version constraints:
           Dependency path: project ':tidepool-engine' (compileClasspath) --> 'org.apache.commons:commons-csv:1.14.1'
           Constraint path: project ':tidepool-engine' (compileClasspath) --> 'org.apache.commons:commons-csv:{strictly 1.11.0}' because of the following reason: Dependency version enforced by Dependency Locking

> There is 1 more failure with an identical cause.

This last message is the one people find confusing, because nothing in the build file says strictly: the constraint comes from the lock file, and its path line says so ("Dependency version enforced by Dependency Locking"). The reverse direction does not fail: with the catalog at [1.10,1.12) and the lock at 1.11.0, changing it to the dynamic 1.+ left the report at 1.+ -> 1.11.0, because the locked version satisfies 1.+; the build kept 1.11.0 even though 1.14.1 existed, which is exactly what locking is for.

In the default mode there are therefore only two ways to change what the build resolves: change the declarations and update the lock in the same commit, or update the lock on its own. The next sections show the second.

Updating the lock state

--write-locks rewrites everything. To move only some modules, --update-locks takes a comma-separated list of module notations; the existing lock state is still used as input, except for the modules named, which are free to resolve again:

./gradlew resolveAndLockAll --update-locks org.apache.commons:commons-csv

The starting point was the lock at 1.11.0 (written when the catalog said [1.10,1.12)) and a catalog that had been widened to 1.+. The lock files afterwards:

commons-codec:commons-codec:1.19.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-io:commons-io:2.20.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-csv:1.14.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
empty=annotationProcessor,testAnnotationProcessor

The module that was named moved from 1.11.0 to 1.14.1, and so did its transitive dependencies commons-io (2.16.1 to 2.20.0) and commons-codec (1.16.1 to 1.19.0), because the resolution rules choose their versions once the lock on the parent is gone. picocli stayed at 4.7.7 in the same run. Wildcards are allowed in the group and the name, but only as the whole value or at the end: org.apache.commons:*, *:guava and org.springframework.spring*:spring* are all valid notations. The option is marked [incubating] in ./gradlew --help on 9.8.1; the behaviour may still change. The same caveat as for --write-locks holds: only the configurations resolved in that build are updated.

This is also the whole renewal workflow of a team that wants fresh dependencies but reviewed ones: a scheduled job (or a bot) runs --write-locks, and the pull request that changes gradle.lockfile is the review. The next section gives the third option, loosening the rules.

Lock modes

The default mode, described so far, requires that existing entries are matched and that resolution adds nothing. Two other modes are selected with lockMode:

Mode Behaviour

LockMode.DEFAULT

Existing lock entries must be matched; a version mismatch, a missing module or an extra module fails the build. A locked configuration with no lock state is resolved normally (and not locked).

LockMode.STRICT

The default rules, and in addition a locked configuration that has no lock state is an error.

LockMode.LENIENT

Dynamic versions are still pinned, but other changes are no longer errors: dependencies may be added or removed, and transitive dependencies may shift as long as dynamic versions stay pinned. Meant for trying nightly or snapshot builds without breaking the build.

Kotlin DSL
dependencyLocking {
    lockAllConfigurations()
    lockMode = LockMode.STRICT
}
Groovy DSL
dependencyLocking {
    lockAllConfigurations()
    lockMode = LockMode.STRICT
}

The default mode lets a configuration without a lock file through, which is the dangerous case: a new project or a new configuration is simply not locked, and nobody notices. With STRICT, deleting the lock file of tidepool-cli and building gives

> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tidepool-engine:compileJava UP-TO-DATE
> Task :tidepool-cli:compileJava FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':tidepool-cli:compileJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').
> Could not resolve all dependencies for configuration ':tidepool-cli:compileClasspath'.
   > Locking strict mode: Configuration ':tidepool-cli:compileClasspath' is locked but does not have lock state.

Use STRICT in CI and for projects that must be reproducible; use LENIENT in a throwaway branch or a job that deliberately tests against newer versions. In LENIENT mode the same dependency added to the engine, with a dynamic version org.slf4j:slf4j-api:2.0.+ that has no lock entry, built without errors, and the report resolved it freely to 2.0.20 (the newest 2.0.x on the day) while the locked modules stayed at their locked versions:

compileClasspath - Compile classpath for source set 'main'.
+--- project ':tidepool-model'
+--- org.apache.commons:commons-csv:1.+ -> 1.14.1
|    +--- commons-io:commons-io:2.20.0
|    \--- commons-codec:commons-codec:1.19.0
+--- org.slf4j:slf4j-api:2.0.+ -> 2.0.20
+--- org.apache.commons:commons-csv:1.14.1 (c)
+--- commons-io:commons-io:2.20.0 (c)
\--- commons-codec:commons-codec:1.19.0 (c)

Note that in this mode the locked entries are no longer strictly (they print as plain 1.14.1 (c)), which is how a dependency can shift.

Ignored dependencies and the location of the lock file

Some dependencies are meant to move: an organisation’s own libraries, for example, which should always be the newest. The extension can exclude them from the lock state (they still resolve, but are neither recorded nor validated); the wildcard rules are the same as for --update-locks. The lock file location can also be changed, which is useful when one project has several variants of its dependencies (the manual’s example is the Scala binary version in the artifact coordinates, with a lock file per version):

Kotlin DSL
allprojects {
    dependencyLocking {
        lockAllConfigurations()
        ignoredDependencies.add("org.slf4j:*")
        lockFile = layout.projectDirectory.file("gradle/locks/${project.name}.lockfile")
    }
}
Groovy DSL
allprojects {
    dependencyLocking {
        lockAllConfigurations()
        ignoredDependencies.add('org.slf4j:*')
        lockFile = layout.projectDirectory.file("gradle/locks/${project.name}.lockfile")
    }
}

With slf4j-api as a dependency of the engine, resolveAndLockAll --write-locks wrote tidepool-engine/gradle/locks/tidepool-engine.lockfile (and the same for the other two projects), and that file listed the three Commons modules but no slf4j-api; the build then passed without any entry for it. (The first gradle.lockfile files from the default location were still on disk from the earlier runs and were simply no longer used.) Ignoring dependencies defeats reproducibility for them by design; the manual suggests separate lock modes or separate lock files instead for anything that matters.

The build script and the settings

The plugins that a build applies are dependencies too, and lockAllConfigurations() does not cover the configuration that holds them. For the buildscript classpath, activate locking there explicitly:

Kotlin DSL
// build.gradle.kts
buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.apache.commons:commons-lang3:3.+")
    }
    configurations.classpath {
        resolutionStrategy.activateDependencyLocking()
    }
}

tasks.register("hello") {
    doLast { println("hello") }
}
Groovy DSL
// build.gradle
buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath 'org.apache.commons:commons-lang3:3.+'
    }
    configurations.classpath {
        resolutionStrategy.activateDependencyLocking()
    }
}

tasks.register('hello') {
    doLast { println 'hello' }
}

(A small separate project, so that it does not disturb the tidepool lock files; a real build would apply a plugin from the Plugin Portal in the same way, see Plugins Overview.) ./gradlew hello --write-locks wrote buildscript-gradle.lockfile, in both DSLs:

# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
org.apache.commons:commons-lang3:3.21.0=classpath
empty=

The third file is settings-gradle.lockfile, already seen above: it locks the dependencies of the settings (the plugins applied in settings.gradle(.kts), and the catalogs). Locking settings dependencies arrived in Gradle 6.8.

Limits and the classic mistakes

  • Do not lock changing versions. Locking is for dynamic versions and ranges, where coordinates identify content. A -SNAPSHOT keeps its coordinates while the content changes, so a lock gives a false sense of reproducibility; the manual says Gradle emits a warning when it persists lock state with changing dependencies present (that warning was not reproduced here). Verification skips snapshots for the same reason, see below.

  • Source dependencies are not locked: the manual lists dependency locking as not applicable to source dependencies.

  • A forgotten --write-locks. The build file is changed, the lock is not, and the failure of the previous section appears on the next machine. Commit the lock file in the same commit as the dependency change; a CI step that runs the lock-writing task and fails on a dirty working tree detects drift.

  • Locking without verification. The lock says "commons-csv 1.14.1", not "these bytes". Use both, see the next section.

  • Hand edits. The header says they can break the build; the only edits that are safe are the ones Gradle makes.

  • Disabling. To stop locking a configuration, remove the activation and run the build once with --write-locks: Gradle drops the stale lock state of every configuration that it resolves and finds no longer locked.

Equivalents in Ant and Maven

Locking has no built-in equivalent in either of the other tools. Ivy has fixed revisions and the deliver task that writes a concrete ivy file for publication (see Ivy: Conflicts & Versions); Maven’s answer is not to use ranges and to pin versions in the POM (see Maven Versions, SNAPSHOTs & Ranges), with the enforcer rules of Maven Dependency Analysis & Convergence to police it. The comparison table is in Dependency Management Fundamentals.

Dependency verification

Dependency verification checks every artifact that Gradle resolves against checksums and signatures that you keep in gradle/verification-metadata.xml. It protects against the three things that locking cannot: a repository that serves different bytes than before, a man in the middle, and a cache that was tampered with. Gradle has had it since 6.2 (February 2020).

Two mechanisms: checksums and signatures

A checksum (SHA-256, say) fingerprints the content: it proves the file is the one whose hash you recorded, and says nothing about who produced it. A signature (a PGP .asc file published next to the artifact) proves who signed it, provided you trust the key; the manual points out that signatures alone may rest on weak hashes, so the strongest setup uses both. Gradle supports MD5, SHA-1, SHA-256 and SHA-512 checksums, of which only SHA-256 and SHA-512 are considered secure today. Signatures are verified only if they are ASCII-armoured .asc files on a remote repository.

Gradle verifies everything its dependency management engine downloads: jars and other artifacts, metadata files (POM, Ivy descriptors and Gradle Module Metadata), plugins (project and settings plugins) and artifacts resolved through advanced resolution APIs. It does not verify changing dependencies (SNAPSHOT versions) or artifacts the build itself produces, because their checksums change constantly. The file is global for the build: one file serves the root project, all subprojects and buildSrc; for a composite build, the file of the current build is used and those of the included builds are ignored.

Bootstrapping with checksums

Verification is enabled the moment gradle/verification-metadata.xml exists. An empty file would make every build fail, so the file is generated:

./gradlew --write-verification-metadata sha256 build

The flag takes a comma-separated list of algorithms (and pgp), resolves every resolvable configuration of the build, downloads the artifacts and computes the checksums. Run with a Gradle user home that had never seen these dependencies, it wrote this file (the schema attribute of the root element and the middle components are shortened):

<?xml version="1.0" encoding="UTF-8"?>
<verification-metadata xmlns="https://schema.gradle.org/dependency-verification" ...>
   <configuration>
      <verify-metadata>true</verify-metadata>
      <verify-signatures>false</verify-signatures>
   </configuration>
   <components>
      <component group="commons-codec" name="commons-codec" version="1.19.0">
         <artifact name="commons-codec-1.19.0.jar">
            <sha256 value="5c3881e4f556855e9c532927ee0c9dfde94cc66760d5805c031a59887070af5f" origin="Generated by Gradle"/>
         </artifact>
         <artifact name="commons-codec-1.19.0.pom">
            <sha256 value="e0f3269fa23de0c83130c5659f5f9514cc5422c0bcdf45f2eae004a78b9fca34" origin="Generated by Gradle"/>
         </artifact>
      </component>
      ...
      <component group="org.apache.commons" name="commons-csv" version="1.14.1">
         <artifact name="commons-csv-1.14.1.jar">
            <sha256 value="32be0e1e76673092f5d12cb790bd2acb6c2ab04c4ea6efc69ea5ee17911c24fe" origin="Generated by Gradle"/>
         </artifact>
         <artifact name="commons-csv-1.14.1.pom">
            <sha256 value="95df735302c0ab92cec05a2b51e2270758e15f0a64186de65662eb148ebd97fd" origin="Generated by Gradle"/>
         </artifact>
      </component>
      ...
      <component group="org.junit" name="junit-bom" version="5.13.1">
         <artifact name="junit-bom-5.13.1.module">
            <sha256 value="33c07ab9724790a6e5859ba07d69117ac530439724545a81c4179e3272c75de8" origin="Generated by Gradle"/>
         </artifact>
         <artifact name="junit-bom-5.13.1.pom">
            <sha256 value="fa68451ea830572ed43ffe51d75b6a05f7a5e665a602a51f49d6be02063a65f3" origin="Generated by Gradle"/>
         </artifact>
      </component>
   </components>
</verification-metadata>

There are eight components for three declared dependencies: Commons CSV with its two transitive modules, picocli, and the parents and BOMs that appear only because their POMs are part of the metadata (org.apache:apache, commons-parent, junit-bom). Because metadata verification is on, every .pom and .module file has its own checksum; with <verify-metadata>false</verify-metadata> the file is much shorter but compromised metadata could add malicious transitive dependencies, which is why the manual advises against it. The origin="Generated by Gradle" marker means "recorded, not reviewed": bootstrapping trusts whatever the repositories serve right now, so the file must be reviewed, ideally against checksums published elsewhere than in the repository (the project’s own website), and the attribute changed to origin="Verified" or a note on where the value came from.

Bootstrap with a clean cache. The first bootstrap of this page ran against the shared Gradle user home, which already held these dependencies, and wrote seven components: the junit-bom-5.13.1.module entry above was missing. A later build with a clean Gradle user home then failed with One artifact failed verification: junit-bom-5.13.1.module, because that file had never been verified before. Gradle documents that artifacts that are only resolved at execution time or through custom resolution code can be missed; this was a different cause (the warm cache), and the practical rule is the same: generate the file with a fresh GRADLE_USER_HOME (or --refresh-dependencies), and prove it by building once more with another fresh one.

Bootstrapping with signatures

Most Maven Central artifacts are signed, so the file can say "trust this key" instead of "trust these bytes":

./gradlew --write-verification-metadata sha256,pgp --export-keys build

Gradle downloads the signature file of each artifact, fetches the public key from a key server, verifies the signature, and writes the key as trusted. The algorithm list must still contain a checksum, because not every artifact is signed: those get the fallback checksum. With a clean Gradle user home, the run wrote this:

<?xml version="1.0" encoding="UTF-8"?>
<verification-metadata xmlns="https://schema.gradle.org/dependency-verification" ...>
   <configuration>
      <verify-metadata>true</verify-metadata>
      <verify-signatures>true</verify-signatures>
      <ignored-keys>
         <ignored-key id="9EB80E92EB2135B1" reason="Key couldn't be downloaded from any key server"/>
      </ignored-keys>
      <trusted-keys>
         <trusted-key id="2DB4F1EF0FA761ECC4EA935C86FDC7E2A11262CB">
            <trusting group="commons-codec"/>
            <trusting group="commons-io"/>
            <trusting group="org.apache.commons"/>
         </trusted-key>
         <trusted-key id="AA417737BD805456DB3CBDDE6601E5C08DCCBB96" group="info.picocli" name="picocli" version="4.7.7"/>
         <trusted-key id="FF6E2C001948C5F2F38B0CC385911F425EC61B51" group="org.junit" name="junit-bom" version="5.13.1"/>
      </trusted-keys>
   </configuration>
   <components>
      <component group="org.apache" name="apache" version="35">
         <artifact name="apache-35.pom">
            <sha256 value="ea297dcd114136e8b8e8b630230d52a76c2fc69f6c5db25d672b1857000728b8" origin="Generated by Gradle" reason="A key couldn't be downloaded"/>
         </artifact>
      </component>
   </components>
</verification-metadata>

Read it from the top. Eight components shrank to one, because everything signed by a key that is now trusted needs no entry of its own. The Apache Commons key is trusted for three groups together, grouped under one trusted-key with nested trusting elements. Picocli’s and the JUnit BOM’s keys were written as trusted only for exactly that module and version. The Apache parent POM was signed with a key that no key server had (it was recorded as an ignored key), so the artifact fell back to a checksum, with a reason saying why. Both the ignored key and the fallback checksum are things to review by hand: ideally find the key and add it to the keyring (see below). Gradle takes an optimistic view while bootstrapping, which is why the documentation recommends checksums first if integrity matters, then signatures.

The key identifiers are full 40-character fingerprints; Gradle requires full fingerprints for pgp and trusted-key entries, not short or long key IDs, to make collision attacks harder (ignored keys may use long IDs, as above). Never trust a key because of the name in its user ID: anyone can create a key with any name, and the right check is that the fingerprint is listed on the project’s own site (Apache projects publish a KEYS file).

The file the flag wrote with --export-keys is the local keyring, below. The elements of <configuration> are:

Element Meaning

verify-metadata

Verify POMs, Ivy files and Gradle Module Metadata too. Default behaviour of generated files: true.

verify-signatures

Check PGP signatures where they exist.

trusted-keys

Keys trusted for a group, module, version or file (attributes group, name, version, file, regex, plus informational origin and reason); several scopes for one key are written as nested trusting elements.

trusted-artifacts

Artifacts that are not verified at all (trust with group, name, version, file, regex, reason).

ignored-keys

Keys that are never used for verification (for example, unavailable ones); an artifact whose keys are all ignored needs a checksum.

key-servers

Key servers to use instead of the defaults, or enabled="false" for the local keyring only.

keyring-format

armored (.keys) or binary (.gpg).

Key servers, keyrings and the offline case

Fetching keys from key servers on every clean checkout is slow and makes CI depend on someone else’s availability. The answer is a keyring in the repository. --export-keys writes the public keys used for verification to two files in gradle/:

gradle/verification-keyring.gpg
gradle/verification-keyring.keys

The .gpg file is binary (compact and editable with gpg), the .keys file is ASCII-armoured (readable in a pull request, mergeable by hand). Commit one: without a keyring-format element Gradle reads whichever exists, and the binary one takes precedence when both are present; with <keyring-format>armored</keyring-format> (or binary) only that format is read and written (since 8.5). Since Gradle 8.0 ./gradlew --export-keys alone exports the keys of an already trusted set, without writing the metadata; and it only stores public key packets and a single user ID per key. A binary keyring should be marked in .gitattributes (*.gpg binary). A key that Gradle cannot download but that you can find can be appended to the .keys file or imported into the .gpg one with gpg.

To make a build refuse the network completely for keys, disable the key servers:

<configuration>
   <key-servers enabled="false"/>
</configuration>

With that in the metadata and the exported .keys file in gradle/, a build in a clean Gradle user home passed (14 s, because it downloaded the dependencies). Without the keyring file, the same build failed with 9 artifacts failed verification for the Commons modules (the list starts with commons-codec-1.19.0.jar): the keys were needed and could not be fetched. When a flag ignores enabled="false": if you ask Gradle to generate metadata and the existing file disables key servers, the setting is ignored so that missing keys can be downloaded.

Gradle caches keys it could not find for 24 hours; ./gradlew build --refresh-keys retries immediately. --refresh-keys was accepted and the build passed (nothing needed refreshing, so nothing visible changed).

The build script and the plugins are verified too

Plugins are dependencies, so they are covered by the same file. The buildscript classpath project of the locking section (with commons-lang3) was bootstrapped with ./gradlew --write-verification-metadata sha256 hello and listed five components: org.apache:apache, commons-lang3, commons-parent, junit-bom and mockito-bom, the last two being BOMs imported by the parent POMs. Changing one checksum of the jar to zeros, and running the build, failed during configuration of the project, before any task:

FAILURE: Build failed with an exception.

* What went wrong:
A problem occurred configuring root project 'buildscript-locking'.
> Dependency verification failed for configuration 'classpath'
  One artifact failed verification: commons-lang3-3.21.0.jar (org.apache.commons:commons-lang3:3.21.0) from repository MavenRepo
  This can indicate that a dependency has been compromised. Please carefully verify the checksums.

  Open this report for more details: file:///work/buildscript-locking/build/reports/dependency-verification/at-1792177244105/dependency-verification-report.html

That is the point of verifying plugins: a build file is code (see Supply-Chain Security), and a plugin artifact runs with the privileges of the build.

Reading a verification failure

Gradle’s failure output has two layers: a summary on the console and an HTML report, linked in the message, with the full detail. The summary of a changed checksum on the tidepool engine (the metadata had the SHA-256 of commons-csv-1.14.1.jar replaced by zeros) is:

> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tidepool-engine:compileJava FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':tidepool-engine:compileJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').
> Dependency verification failed for configuration ':tidepool-engine:compileClasspath'
  One artifact failed verification: commons-csv-1.14.1.jar (org.apache.commons:commons-csv:1.14.1) from repository MavenRepo
  This can indicate that a dependency has been compromised. Please carefully verify the checksums.

  Open this report for more details: file:///work/tidepool/build/reports/dependency-verification/at-1792176397510/dependency-verification-report.html

That is short, and in CI you cannot open a file in the workspace of a finished job. The property org.gradle.dependency.verification.console=verbose (in gradle.properties) prints the full text, including the expected and the actual value, which is what you need to decide whether this is a typo, a different binary in another repository or an attack:

> Dependency verification failed for configuration ':tidepool-engine:compileClasspath':
    - On artifact commons-csv-1.14.1.jar (org.apache.commons:commons-csv:1.14.1) in repository 'MavenRepo': expected a 'sha256' checksum of '0000000000000000000000000000000000000000000000000000000000000000' but was '32be0e1e76673092f5d12cb790bd2acb6c2ab04c4ea6efc69ea5ee17911c24fe'

  This can indicate that a dependency has been compromised. Please carefully verify the checksums.

The other failures that matter, all produced here:

Untrusted signature. The trusted-key of picocli was removed from the metadata:

> Dependency verification failed for configuration ':tidepool-cli:compileClasspath':
    - On artifact picocli-4.7.7.jar (info.picocli:picocli:4.7.7) in repository 'MavenRepo': Artifact was signed with key 'AA417737BD805456DB3CBDDE6601E5C08DCCBB96' (...) and passed verification but the key isn't in your trusted keys list.
    - On artifact picocli-4.7.7.pom (info.picocli:picocli:4.7.7) in repository 'MavenRepo': Artifact was signed with key 'AA417737BD805456DB3CBDDE6601E5C08DCCBB96' (...) and passed verification but the key isn't in your trusted keys list.

(The name and e-mail address in the key’s user ID are replaced by (...).) Note the meaning: the signature is valid; Gradle simply does not know whether you trust the signer. Missing metadata. A new direct dependency was added to the engine without updating the file:

> Dependency verification failed for configuration ':tidepool-engine:compileClasspath'
  4 artifacts failed verification:
    - slf4j-api-2.0.17.jar (org.slf4j:slf4j-api:2.0.17) from repository MavenRepo
    - slf4j-api-2.0.17.pom (org.slf4j:slf4j-api:2.0.17) from repository MavenRepo
    - slf4j-bom-2.0.17.pom (org.slf4j:slf4j-bom:2.0.17) from repository MavenRepo
    - slf4j-parent-2.0.17.pom (org.slf4j:slf4j-parent:2.0.17) from repository MavenRepo
  If the artifacts are trustworthy, you will need to update the gradle/verification-metadata.xml file. For more on how to do this, please refer to https://docs.gradle.org/9.8.1/userguide/dependency_verification.html#sec:troubleshooting-verification in the Gradle documentation.

The advice in that message points to the anchor sec:troubleshooting-verification, which does not exist on the 9.8.1 page; the section on failures is sec:dealing-verification-failure (the same slip is noted on Supply-Chain Security). The same message shows why one new dependency means four artifacts: the jar, its POM, and the BOM and parent POM that the POM imports.

What to do depends on the failure. A missing entry is fixed by bootstrapping again (incremental, below). An untrusted key: check the fingerprint against the project’s website, then add the key. A checksum mismatch is the serious one: the options are a damaged or tampered file in the local cache (delete it and let Gradle download again), the same artifact published slightly differently to two repositories (accept both with also-trust, below), or a compromised artifact (inform the maintainers and the repository). Watch also for name squatting and repository shadowing, where look-alike coordinates, or the official ones in a repository that comes first, give the wrong file; content filtering (see Repositories & Content Filtering) and verification together close that gap. The HTML report explains each failure kind (checksum mismatch, key not trusted, signature did not match, key ignored, key not found, checksum missing, deleted artifact) and what to do about it.

Since Gradle 9.7, the console and the HTML report add a note when a signing key cannot be found on any key server: they report how many other keys are already trusted for that module or group. A non-zero count suggests that the key was rotated, a zero count that you are trusting something for the first time (not run here; described in the 9.7.0 release notes).

Verification modes

Verification is expensive for some workflows (frequent dependency upgrades, for example) and not for others (CI). There are three modes:

Mode Behaviour

strict (default)

Fails as early as possible, so no compromised dependency is used.

lenient

Prints the failures but lets the build continue.

off

No verification.

They are chosen with --dependency-verification (-F), the system property org.gradle.dependency.verification, or in gradle.properties. With the tampered checksum from above, --dependency-verification lenient printed the verification failure as plain text and the build ended in BUILD SUCCESSFUL:

> Task :tidepool-engine:compileJava
Dependency verification failed for configuration ':tidepool-engine:compileClasspath'
One artifact failed verification: commons-csv-1.14.1.jar (org.apache.commons:commons-csv:1.14.1) from repository MavenRepo
This can indicate that a dependency has been compromised. Please carefully verify the checksums.

and -F off printed nothing and ran the build normally. A sensible policy: strict everywhere that matters, lenient on a developer’s machine while upgrading, never off in CI. (To turn off verification for one configuration, for example one that a plugin uses to look up newer versions, call resolutionStrategy.disableDependencyVerification() on it; Gradle prints a warning when it is used.) The related <trusted-artifacts> entries below do the same job declaratively.

Updating the metadata

When a dependency changes, the build fails (as above) and the file needs more entries. Re-running the bootstrap adds them and keeps everything else, including hand-written entries, comments and trusted keys:

./gradlew --write-verification-metadata sha256,pgp compileJava

With the slf4j dependency added, the only change to the signature-based file was one line: the key of the SLF4J group was trusted.

         <trusted-key id="60200AC4AE761F1614D6C46766D68DAA073BE985" group="org.slf4j"/>

and the build then passed. Always use the same list of algorithms as the first time. Gradle never deletes entries (it cannot know that a version is no longer used), so the file only grows; review the diff in every pull request, remove entries for old versions and change the origin="Generated by Gradle" markers.

Dry-run mode. To see what bootstrapping would write without touching the file, combine the flag with --dry-run:

./gradlew --write-verification-metadata sha256 --dry-run compileJava
:tidepool-engine:compileJava SKIPPED
:tidepool-cli:compileJava SKIPPED

BUILD SUCCESSFUL in 349ms

(Tasks are not executed, so it is fast, but it misses dependencies that are resolved at execution time.) The proposal went to gradle/verification-metadata.dryrun.xml. With the checksum of Commons CSV still wrong in the real file, the difference between the two was a new also-trust element:

<             <sha256 value="0000000000000000000000000000000000000000000000000000000000000000" origin="Generated by Gradle"/>
---
>             <sha256 value="0000000000000000000000000000000000000000000000000000000000000000" origin="Generated by Gradle">
>                <also-trust value="32be0e1e76673092f5d12cb790bd2acb6c2ab04c4ea6efc69ea5ee17911c24fe"/>
>             </sha256>

That is the second use of dry runs: Gradle’s bootstrapping accepts whatever it sees, including a changed checksum, by adding the new value as an alternative. A reviewer would stop here and ask why the artifact changed. The manual also suggests dry-run as a way to clean up the file: move the old file away, run with --dry-run and compare, remembering that both modes are incremental and copy existing keys.

Writing and tuning the file by hand

You can write entries yourself, and for checksums taken from the project’s website you should. The attributes origin and reason are free text that Gradle preserves but never evaluates; since 9.7 they also exist on pgp entries and trusted-key elements, and the files Gradle 9.7 and later writes use the schema dependency-verification-1.4.xsd (older files are read unchanged). The constructs not used in the generated files above:

<configuration>
   <verify-metadata>true</verify-metadata>
   <verify-signatures>true</verify-signatures>
   <key-servers>
      <key-server uri="https://keyserver.ubuntu.com"/>
   </key-servers>
   <trusted-artifacts>
      <trust group="org.junit" name="junit-bom"/>
      <trust file=".*-sources[.]jar" regex="true" reason="IDE downloads"/>
   </trusted-artifacts>
   <trusted-keys>
      <trusted-key id="2DB4F1EF0FA761ECC4EA935C86FDC7E2A11262CB">
         <trusting group="commons-codec"/>
         <trusting group="commons-io"/>
         <trusting group="org.apache.commons"/>
      </trusted-key>
   </trusted-keys>
</configuration>

trusted-artifacts means no verification at all for the matching artifacts: use it for artifacts produced inside your organisation, for sources and javadoc jars that an IDE downloads (otherwise importing a project fails), and nothing else. The fragment above ran: with the org.junit:junit-bom trust element in place the build passed with no component entry for the BOM, and with that line removed it failed with One artifact failed verification: junit-bom-5.13.1.pom. For one artifact, the two individual forms:

<component group="org.apache.pdfbox" name="pdfbox" version="2.0.17">
   <artifact name="pdfbox-2.0.17.jar">
      <sha512 value="7e11e54a21c395d461e59552e88b0de0ebaf1bf9d9bcacadf17b240d9bbc29bf6beb8e36896c186fe405d287f5d517b02c89381aa0fcc5e0aa5814e44f0ab331" origin="PDFBox official site"/>
   </artifact>
</component>
<component group="org.apache.pdfbox" name="pdfbox" version="2.0.17">
   <artifact name="pdfbox-2.0.17.pom">
      <sha256 value="a2023504cfd611332177f96358b6f6db26e43d96e8ef4cff59b0f5a2bee3c1e1">
         <also-trust value="ff513db0361fd41237bef4784968bc15aae478d4ec0a9496f811072ccaf3841d"/>
      </sha256>
   </artifact>
</component>

(Two excerpts shown together; the values come from the Gradle manual’s examples, they are illustrative and were not used in a build.) Several checksums for one artifact are all checked, so a second weaker one adds a little assurance when a site publishes only MD5 or SHA-1. also-trust accepts a second legitimate checksum, which happens when the same artifact is published to two repositories by two builds. An ignored-key can be given at artifact level too, when an artifact’s signature is known to be wrong but its checksum has been checked by hand (a checksum must be given too, otherwise all of the artifact’s keys are ignored and Gradle still fails).

Verification and the configuration cache

The configuration cache has supported dependency verification since Gradle 8.1: changes to the verification file and to the keyring files invalidate the cache entry. In the 9.8.1 runs here, the verification-enabled builds ran with --configuration-cache without problems. When a verification error occurs while Gradle is writing a configuration cache entry (because the classpath of a task is serialized), it comes wrapped in a "configuration cache state could not be cached" failure whose cause is the verification message; the same applies to lock errors. Look for the Dependency verification failed line in the stack of causes, or rerun with --no-configuration-cache.

The dependency cache

When Gradle resolves a module for the first time it queries a repository, downloads the descriptor and the artifacts, and stores them below the Gradle user home. Everything later in this section is about when Gradle uses that store and when it asks the repository again.

What is in the cache

The dependency cache lives in $GRADLE_USER_HOME/caches (by default ~/.gradle/caches). Run with an empty Gradle user home to see it:

$ ls $GRADLE_USER_HOME/caches
9.8.1
CACHEDIR.TAG
jars-9
journal-1
modules-2

$ ls $GRADLE_USER_HOME/caches/modules-2
files-2.1
metadata-2.107
modules-2.lock
resources-2.1

The directory modules-2 is the module cache; 9.8.1 and jars-9 belong to the running Gradle version (generated build logic, compiled build scripts). Inside the module cache, files-2.1 is the file store: downloaded artifacts and raw metadata files, kept under the SHA-1 of their content. The listing for one cached module:

modules-2/files-2.1/io.tidepool/demo-lib/1.0.0/60344e7d7b3a95a867634b435253215a877968ee/demo-lib-1.0.0.jar
modules-2/files-2.1/io.tidepool/demo-lib/1.0.0/64e0e2d2b386cadc83f53a97c4cf1b55762077c5/demo-lib-1.0.0.pom
modules-2/files-2.1/io.tidepool/demo-lib/1.0.0/a132021bc68cb3606aba144291c12094648a315/demo-lib-1.0.0.module

(demo-lib is the small library used in the demonstrations below.) The directory names are the SHA-1 of the files, which was checked here with shasum -a 1 for the jar and the POM. Storing by checksum means two repositories that serve different files for the same coordinates (a re-published release, or a SNAPSHOT) never overwrite each other, and the same bytes are stored once. metadata-2.107 is a set of binary files (module-metadata.bin, module-versions.bin, module-artifact.bin, resource-at-url.bin and the descriptors directory) that record the results of resolution: which concrete version a dynamic version resolved to, the parsed metadata of each module, the pointer from each artifact to its file, and the absence of a module or artifact in a repository, so that a missing resource is not asked for again and again. Each entry records the repository it came from and a timestamp, which is what the expiry rules below use.

Two consequences matter in practice. First, the metadata is stored per repository (identified by URL, type and layout), and an artifact that was resolved from one repository stays tied to it: Gradle will not silently resolve it from another, and a build that works on one machine can fail on another where the repository differs. Second, the format version in the name, metadata-2.107, changes with the Gradle version: that value is used by Gradle 8.11 and later (checked here on 9.8.1), metadata-2.106 by 8.2 to 8.10.2, metadata-2.105 by 8.1 and metadata-2.100 by 8.0 (Gradle 7.x and older used other numbers). The table matters when you seed a cache for another Gradle version, see the CI section.

Gradle also tries to reuse artifacts from the local Maven repository (~/.m2/repository), but only if the file matches the checksum published by the remote repository; mavenLocal() as a declared repository is a different thing and is discouraged, see Repositories & Content Filtering. The cache is also safe for concurrent builds: access to the binary store is guarded by file locks (modules-2.lock), released during slow operations such as downloads. That works across Gradle processes that can see each other’s locks; it does not work for containers that share the directory through different mounts.

Unused entries are cleaned up: Gradle records which files were accessed, scans the cache at most once every 24 hours and removes what has not been used for more than 30 days. The retention can be configured with an init script in the Gradle user home (see Directories & Logging and Init Scripts & the Tooling API):

Kotlin DSL
// $GRADLE_USER_HOME/init.d/cache-retention.init.gradle.kts
beforeSettings {
    caches {
        downloadedResources.setRemoveUnusedEntriesAfterDays(45)
        cleanup = Cleanup.DEFAULT
    }
}
Groovy DSL
// $GRADLE_USER_HOME/init.d/cache-retention.init.gradle
beforeSettings { settings ->
    settings.caches {
        downloadedResources.setRemoveUnusedEntriesAfterDays(45)
        cleanup = Cleanup.DEFAULT
    }
}

Both scripts were accepted by Gradle 9.8.1 (./gradlew help --task build ran; the cleanup itself takes 24 hours of cache age and was not observed). In Groovy, the closure of beforeSettings receives the settings object, so the configuration goes through settings.caches; written like the Kotlin version, it failed with Could not find method caches() on the build.

A repository to experiment with

To watch caching behaviour you need a repository you control. The examples use a tiny library, io.tidepool:demo-lib, published with the maven-publish plugin to a directory, and served from localhost:

Kotlin DSL
// producer/build.gradle.kts
plugins {
    `java-library`
    `maven-publish`
}

group = "io.tidepool"
version = providers.gradleProperty("libVersion").get()

publishing {
    publications {
        register<MavenPublication>("maven") {
            from(components["java"])
        }
    }
    repositories {
        maven {
            name = "local"
            url = uri(layout.projectDirectory.dir("../repo"))
        }
    }
}
Groovy DSL
// producer/build.gradle
plugins {
    id 'java-library'
    id 'maven-publish'
}

group = 'io.tidepool'
version = providers.gradleProperty('libVersion').get()

publishing {
    publications {
        maven(MavenPublication) {
            from components.java
        }
    }
    repositories {
        maven {
            name = 'local'
            url = layout.projectDirectory.dir('../repo')
        }
    }
}

./gradlew publish -PlibVersion=1.0.0 writes io/tidepool/demo-lib/1.0.0/ (jar, POM, Gradle Module Metadata and checksums) and a maven-metadata.xml listing under repo/; publishing is the subject of Publishing. The directory was served with python3 -m http.server 8099 --bind 127.0.0.1 --directory repo, whose log lines are quoted below as plain request paths. The consumer declares the repository over HTTP (hence the explicit opt-in, since plain HTTP is refused by default) and has a task that prints the jar it resolved. Both DSLs were run:

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

dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        maven {
            url = uri("http://localhost:8099/")
            isAllowInsecureProtocol = true
        }
    }
}

// consumer/build.gradle.kts
plugins {
    `java-library`
}

dependencies {
    implementation("io.tidepool:demo-lib:1.+")
}

tasks.register("showVersion") {
    val names = configurations.runtimeClasspath.flatMap { c ->
        c.incoming.artifacts.resolvedArtifacts.map { arts -> arts.map { it.file.name } }
    }
    doLast { println("resolved: " + names.get()) }
}
Groovy DSL
// consumer/settings.gradle
rootProject.name = 'consumer'

dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        maven {
            url = uri('http://localhost:8099/')
            allowInsecureProtocol = true
        }
    }
}

// consumer/build.gradle
plugins {
    id 'java-library'
}

dependencies {
    implementation 'io.tidepool:demo-lib:1.+'
}

tasks.register('showVersion') {
    def names = configurations.named('runtimeClasspath').flatMap { c ->
        c.incoming.artifacts.resolvedArtifacts.map { arts -> arts.collect { it.file.name } }
    }
    doLast { println 'resolved: ' + names.get() }
}

The task reads the resolved artifacts through providers, which keeps it compatible with the configuration cache; the same task in the first Kotlin version captured a top-level val and the configuration cache refused it (cannot serialize Gradle script object references), which is why the val lives inside the task registration. In the Groovy build the configuration must be looked up with configurations.named('runtimeClasspath') to get a provider to flatMap: configurations.runtimeClasspath is the configuration itself there, and calling flatMap on it failed with No signature of method …​ flatMap() is applicable. The first run, with a Gradle user home that had never seen the library:

./gradlew --configuration-cache showVersion
Calculating task graph as no cached configuration is available for tasks: showVersion

> Task :showVersion
resolved: [demo-lib-1.0.0.jar]

BUILD SUCCESSFUL in 9s
1 actionable task: 1 executed
Configuration cache entry stored.

and the repository saw four requests:

GET /io/tidepool/demo-lib/maven-metadata.xml
GET /io/tidepool/demo-lib/1.0.0/demo-lib-1.0.0.pom
GET /io/tidepool/demo-lib/1.0.0/demo-lib-1.0.0.module
GET /io/tidepool/demo-lib/1.0.0/demo-lib-1.0.0.jar

First the version listing (to resolve 1.+), then the POM, the Gradle Module Metadata and the jar of the selected version. The 9s includes unpacking the wrapper distribution of the new Gradle user home.

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

Dynamic versions are cached for 24 hours

A new version, 1.1.0, was published to the repository. The same build, twice, with and without the configuration cache:

$ ./gradlew --configuration-cache showVersion
Reusing configuration cache.
resolved: [demo-lib-1.0.0.jar]
Configuration cache entry reused.

$ ./gradlew showVersion
resolved: [demo-lib-1.0.0.jar]

Both still resolved 1.0.0 although 1.1.0 existed. The first is the configuration cache doing its job (the resolution is part of the cached task graph); the second shows the dependency cache: Gradle caches the resolution of a dynamic version, and the resolution of a changing module (a SNAPSHOT, or a dependency declared with isChanging = true), for 24 hours by default, and does not contact the repository during that time (the log of the server for the second run was empty). The knobs are on the resolution strategy of a configuration, and take a time and a unit:

Kotlin DSL
configurations.all {
    resolutionStrategy {
        cacheDynamicVersionsFor(10, "minutes")
        cacheChangingModulesFor(4, "hours")
    }
}
Groovy DSL
configurations.all {
    resolutionStrategy {
        cacheDynamicVersionsFor 10, 'minutes'
        cacheChangingModulesFor 4, 'hours'
    }
}

cacheDynamicVersionsFor controls how long the answer to "what is the newest 1.x?" is trusted; cacheChangingModulesFor controls how long the metadata and artifacts of a changing module are. A short window means more requests to the repository on every build, which costs time; a value of 0 means "ask every time". With both set to zero seconds in the consumer (the block above with 0, "seconds"), and 1.2.0 published, the next run resolved the new version at once (the build file had changed, so the configuration cache entry was recalculated), with the requests

GET /io/tidepool/demo-lib/maven-metadata.xml
GET /io/tidepool/demo-lib/1.2.0/demo-lib-1.2.0.pom
GET /io/tidepool/demo-lib/1.2.0/demo-lib-1.2.0.module
GET /io/tidepool/demo-lib/1.2.0/demo-lib-1.2.0.jar

and resolved: [demo-lib-1.2.0.jar]. A SNAPSHOT or a locked build never needs this: a lock removes the question, and a release needs no refresh. Treat a short TTL as a development setting for the modules that change, not as a global default.

--refresh-dependencies

When the cache and the repositories disagree (a repository was misconfigured, a "release" was republished, a new dynamic version is wanted now), --refresh-dependencies (-U) tells Gradle to ignore all cached entries for modules and artifacts for this one build. Starting again from 1.0.0 cached with 1.1.0 published, the 24-hour window above holds, and:

./gradlew --configuration-cache --refresh-dependencies showVersion
Calculating task graph as configuration cache cannot be reused due to --refresh-dependencies
resolved: [demo-lib-1.1.0.jar]
Configuration cache entry stored.
GET /io/tidepool/demo-lib/maven-metadata.xml
GET /io/tidepool/demo-lib/1.1.0/demo-lib-1.1.0.pom
GET /io/tidepool/demo-lib/1.1.0/demo-lib-1.1.0.module
GET /io/tidepool/demo-lib/1.1.0/demo-lib-1.1.0.jar

Two things in that output are worth noting. The option makes the configuration cache entry unusable (the message says so), because the resolution has to run again. And the requests are for the new version only: the already cached 1.0.0 files were not asked for again. The manual is explicit that --refresh-dependencies is widely misunderstood as "download everything again". Gradle recalculates dynamic versions, invalidates listing caches, refreshes metadata and downloads artifacts, but it checks cached ones with HEAD requests and compares published checksums, and downloads again only what differs. So refreshing is cheap when nothing changed, and it has an effect only for dynamic or changing dependencies, or a repository that now serves different content. If a SNAPSHOT or a dynamic dependency is not moving, the lasting fix is the TTL, not a habit of typing -U.

Offline mode

--offline tells Gradle to use the cache for everything, regardless of expiry, and never touch the network. With 1.3.0 published (and the TTLs at zero, which would normally have forced a request) the build ran with the server still up:

$ ./gradlew --offline showVersion
> Task :showVersion
resolved: [demo-lib-1.2.0.jar]

and the server log stayed empty: it used the cached answer (1.2.0) instead of asking for 1.3.0. After stopping the server, a normal build fails with the connection error of every network failure (the part of the message that matters):

> Task :showVersion FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':showVersion' (registered in build file 'build.gradle.kts').
> Could not resolve all artifacts for configuration ':runtimeClasspath'.
   > Could not resolve io.tidepool:demo-lib:1.+.
     Required by:
         root project 'consumer'
      > Failed to list versions for io.tidepool:demo-lib.
         > Unable to load Maven meta-data from http://localhost:8099/io/tidepool/demo-lib/maven-metadata.xml.
            > Could not HEAD 'http://localhost:8099/io/tidepool/demo-lib/maven-metadata.xml'.
               > Got socket exception during request. It might be caused by SSL misconfiguration
                  > Connect to localhost:8099 [localhost/127.0.0.1, localhost/0:0:0:0:0:0:0:1] failed: Connection refused

(The "SSL misconfiguration" hint is generic; this repository is plain HTTP.) The same build with --offline printed resolved: [demo-lib-1.2.0.jar] and BUILD SUCCESSFUL. And a module that was never cached cannot be found offline: with an extra dependency io.tidepool:never-cached:2.1.0,

> Task :showVersion FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':showVersion' (registered in build file 'build.gradle.kts').
> Could not resolve all artifacts for configuration ':runtimeClasspath'.
   > Could not resolve io.tidepool:never-cached:2.1.0.
     Required by:
         root project 'consumer'
      > No cached version of io.tidepool:never-cached:2.1.0 available for offline mode.

Offline mode is the cheapest way to prove that a build’s dependencies are all in the cache, which is what a container with a pre-filled cache needs. The same switch exists as a property (org.gradle.offline, see Build Environment & Properties) and as a command-line option in Command-Line Interface. Offline mode with the dependency verification of this page works as long as the metadata and keyring are complete: verification needs no network when the keys are in the keyring.

Seeding and sharing the cache in CI

CI builds in ephemeral containers start with an empty Gradle user home, so each one downloads everything. The manual describes two remedies. They work because the module cache is stored with relative paths.

Copying a cache. Copy $GRADLE_USER_HOME/caches/modules-2 (not the *.lock and gc.properties files) into the same place of another Gradle user home. The Gradle versions that created and consume the cache must have the same format numbers, otherwise Gradle still contacts the repositories for what it lacks; the formats are:

Module cache File cache Metadata cache Gradle versions

modules-2

files-2.1

metadata-2.97

6.8 to 7.4

modules-2

files-2.1

metadata-2.99

7.5 to 7.6.1

modules-2

files-2.1

metadata-2.101

7.6.2

modules-2

files-2.1

metadata-2.100

8.0

modules-2

files-2.1

metadata-2.105

8.1

modules-2

files-2.1

metadata-2.106

8.2 to 8.10.2

modules-2

files-2.1

metadata-2.107

8.11 and above (9.8.1 verified here)

A shared read-only cache. Instead of copying, mount one cache read-only into all containers and point the environment variable GRADLE_RO_DEP_CACHE at the directory that contains modules-2. Gradle then looks in both the writable cache in its own user home and the shared one without locking (so many builds may read it at the same time), takes what the shared cache has, and downloads and writes only what is missing. The seeding build should produce the shared cache in a job of its own, copy it away (never use a live Gradle user home, which may be locked or modified) and publish it as a volume. The feature is marked incubating in the manual. Demonstration: a copy of the cache that contained demo-lib 1.1.0 (without lock and gc.properties files, made read-only with chmod -R a-w) as /work/ro/modules-2, a consumer with a fixed version 1.1.0, a brand-new Gradle user home, and the server stopped:

$ GRADLE_RO_DEP_CACHE=/work/ro ./gradlew --configuration-cache showVersion
resolved: [demo-lib-1.1.0.jar]
BUILD SUCCESSFUL in 9s

The writable cache of that new home contained metadata-2.107 and modules-2.lock but no files-2.1 at all: the jar, POM and module file were served from the shared cache. The control, an equally fresh Gradle user home without the variable, failed on the first file it needed:

         > Could not GET 'http://localhost:8099/io/tidepool/demo-lib/1.1.0/demo-lib-1.1.0.pom'.
               > Connect to localhost:8099 [localhost/127.0.0.1, localhost/0:0:0:0:0:0:0:1] failed: Connection refused
BUILD FAILED in 13s

The consumer in this demonstration used a fixed version, because a dynamic one would still ask the (stopped) repository for the version listing. Combining the shared cache with dependency verification was not run here. In principle a seeded cache plus --offline, a committed lock file and the verification file give a CI build that is repeatable and does not need the internet.

Putting the three together

A repeatable and defensible dependency setup for a Gradle build, in order of effort:

What How Committed files

Same versions everywhere

dependencyLocking { lockAllConfigurations(); lockMode = LockMode.STRICT }, --write-locks by a scheduled job or by hand

gradle.lockfile, settings-gradle.lockfile, buildscript-gradle.lockfile

Same bytes everywhere

--write-verification-metadata sha256,pgp --export-keys on a clean Gradle user home, reviewed by a human

gradle/verification-metadata.xml, one verification-keyring.* file

Fewer downloads

Gradle user home cached between CI jobs, or GRADLE_RO_DEP_CACHE; TTLs left at 24 hours

Nothing (the cache is not committed)

Fast failure when the network is gone

--offline in CI jobs that must not download

Nothing

Order of commands in a pull request that updates a dependency: change the catalog, run the lock-writing task, run the verification bootstrap (with the same algorithms as before), read the diff of gradle.lockfile and verification-metadata.xml, and commit all three. A CI check that runs the lock and verification tasks and fails if the working tree changed catches a forgotten file before it reaches a colleague. Keep dependency resolution out of tasks if you can: the new configuration-cache-safe APIs that arrived in 9.7 make resolving at execution time possible without breaking the cache (see Configuration Cache & Isolated Projects).

Release history and Gradle 8.14

Release Change

4.8

Dependency locking for dynamic versions and ranges, with one lock file per configuration (the format has since been replaced by the single gradle.lockfile).

6.2 (2020-02-17)

Dependency verification (checksums and signatures) and the shared read-only dependency cache (GRADLE_RO_DEP_CACHE).

6.8

Locking of the dependencies of Gradle settings (settings-gradle.lockfile).

8.0 (2023-02-13)

reason attributes in the verification metadata; ./gradlew --export-keys without writing the metadata; the module cache formats of the table above start with metadata-2.100.

8.1 (2023-04-12)

The configuration cache fully supports dependency verification and detects changes to the metadata and keyring files.

8.5

keyring-format setting; case-insensitive key IDs in the metadata.

9.7 (2026-08-06)

origin and reason on pgp and trusted-key entries and schema 1.4; verification reports other trusted keys for the same module or group when a key is missing; configuration-cache-safe dependency-resolution APIs for tasks.

The features were checked in the release notes cited in the references; the dates of all releases are in the history table of Versions, Compatibility & Release History. The formats and flags used in this page are the same in Gradle 8.14 for locking and for the cache; the verification file written by 8.14 uses an older schema version (the 8.5 notes show 1.3) and has no origin on keys. Differences of the 8.14 line from 9.8.1 for these features beyond what the release notes list were not checked, and none of this page was run on 8.14 (not run here). The one visible source-level difference met here is in the Groovy DSL: configurations.findAll { ... } on a collection of configurations works on 8.14 and is deprecated on 9.8.1 (use matching), scheduled for removal in Gradle 10.

Where the books are outdated

Gradle in Action (Gradle 1.7) covers the dependency cache and its commands (--offline, --refresh-dependencies) in its dependency management chapter. Those two switches still exist with the same meaning (the second as --refresh-dependencies or -U). Everything else on this page postdates the book: dependency locking arrived in Gradle 4.8 (2018) and dependency verification in 6.2 (2020), so neither appears in the book, and Gradle 6.2 was also the release of the shared read-only cache. The layout of the cache has changed since 1.7 as well: the directories are today modules-2/files-2.1 and metadata-2.107 (the number changes with Gradle versions), so a path copied from the book will not exist. Treat anything the book says about the cache directories and their default timeouts as history, and use the current user manual; resolutionStrategy.cacheDynamicVersionsFor and cacheChangingModulesFor themselves are current API, used in the caching section above.

Equivalents in Ant and Maven

Need Ant with Ivy Maven Gradle

Pin dynamic versions

Fixed revisions; deliver

No ranges; versions fixed in the POM

Dependency locking (gradle.lockfile)

Check downloaded bytes

Checksum algorithms of Ivy resolvers; signers only sign what Ivy publishes

Repository checksums, and trusted checksums of Resolver (see Maven Resolver)

verification-metadata.xml (SHA-256/512, PGP)

Cache of artifacts

Ivy cache (.ivy2/cache), TTL per resolver

Local repository (~/.m2/repository)

modules-2 below the Gradle user home, checksum-addressed

Refresh

refresh="true" on ivy:resolve, ttl rules

-U, update policies

--refresh-dependencies, cacheDynamicVersionsFor

Offline

No switch; a property of the build file (see Ivy Overview)

-o / <offline>

--offline

Shared cache for CI

A shared cache directory (lock strategy)

No equivalent; a mirror or repository manager near the build

GRADLE_RO_DEP_CACHE

The other tools' pages are Ivy: Settings & Resolvers (caches, lock strategies and TTL), Maven Resolver (trusted checksums and repository filtering), Running Maven (CLI) (-o, -U) and Maven Repositories, Mirrors & Repository Managers (offline builds and cached failures); the tool-neutral treatment is in Repositories & Publishing Across Tools and Comparing Ant, Maven & Gradle. The next Gradle pages continue with Dependency Resolution, Conflicts & Capabilities and the build cache, which is a different cache (task outputs, not dependencies).

Common problems

Symptom Cause and fix

Resolved '…​' which is not part of the dependency lock state

A dependency was added and the lock not updated. Run the lock-writing task with --write-locks (or --update-locks <module>) and commit.

Did not resolve '…​' which is part of the dependency lock state

A dependency was removed and the lock still lists it. Same fix.

Cannot find a version …​ {strictly …​} …​ enforced by Dependency Locking

A declared version is higher than the locked one. Update the lock.

Locking strict mode: Configuration …​ is locked but does not have lock state

LockMode.STRICT and a new project or configuration without a lock file. Write the lock state.

./gradlew dependencies --write-locks writes no lock files for subprojects

Unqualified dependencies runs in one project. Name the projects or use a resolve-all task.

A configuration cache serialization error instead of the lock or verification message

Gradle wraps resolution errors that occur while storing the classpath of a task. Look for the cause in the report, or rerun with --no-configuration-cache.

Dependency verification failed after a bootstrap from a warm cache

The shared Gradle user home had artifacts the bootstrap never saw. Re-bootstrap with a fresh GRADLE_USER_HOME.

IDE import fails verification

Sources and javadoc jars are downloaded and verified too. Add trusted-artifacts regexes for -sources.jar and -javadoc.jar.

CI cannot fetch keys

Export the keyring with --export-keys, commit one format, and optionally set <key-servers enabled="false"/>.

A SNAPSHOT or 1.+ does not update

The 24-hour TTL. cacheDynamicVersionsFor and cacheChangingModulesFor, or --refresh-dependencies once.

--refresh-dependencies "did nothing"

It re-checks; it does not force a download. If nothing changed in the repository, nothing is downloaded.

The findAll deprecation in the Groovy helper task

Use configurations.matching { ... }.