Version Catalogs

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 (macOS; the daemon ran on JDK 26 and no toolchain was involved). Every example was run through the wrapper in scratch copies of the tidepool project, once with Kotlin DSL build files and once with Groovy DSL build files. The TOML file and its error messages are the same for both DSLs, so the experiments that change only the TOML file (the error gallery, the alias names) were run in the Kotlin DSL copy; everything that involves a build script or a settings script was run in both. 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

The commands use --console=plain, and gradle.properties contains org.gradle.configuration-cache=true, so every example ran with the configuration cache on and each one says whether it is compatible with it. In the listings the blank lines, the Try: hints of failures, the [Incubating] Problems report line and the Calculating task graph line of the first run are left out, the order of unrelated tasks varies from run to run (a line with three dots marks trimmed lines), and the scratch directory is shown as /work/tidepool. 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". Publishing a catalog goes only to a file:// repository below the catalog project’s build directory; publishing to a remote repository is not run here.

A version catalog is a list of dependency coordinates and plugin versions kept in one file, from which Gradle generates type-safe names for the build scripts. It is the answer to the oldest maintenance problem of a multi-project build: the same version number typed in a dozen places, and a dozen places to change when a library moves. This page covers the file format, the generated accessors, every way of declaring, sharing and publishing a catalog, how a catalog interacts with platforms and with buildSrc or build-logic, and the problems Gradle reports, using the tidepool toolkit of the section (a model library, an engine library that reads a CSV file with Commons CSV, and a command line application that uses picocli). The dependency vocabulary (configurations, api and implementation) is on Declaring Dependencies & Configurations, where the versions end up being chosen is on Platforms, Constraints & Rich Versions and Dependency Resolution, Conflicts & Capabilities, and the cross-tool picture is on Dependency Management Fundamentals.

Why a catalog

Before catalogs, the usual place for a shared version was an ext property in the root build script (or a gradle.properties entry). That is the style of the book Gradle in Action (Gradle 1.7), and it still works on Gradle 9.8.1: the same tidepool build with the versions in ext resolves to exactly the same graph.

Kotlin DSL: build.gradle.kts (root project)
group = "io.tidepool"
version = "1.0.0"

val commonsCsvVersion by extra("1.14.1")
val picocliVersion by extra("4.7.7")
Kotlin DSL: tidepool-engine/build.gradle.kts
plugins {
    `java-library`
}

val commonsCsvVersion: String by rootProject.extra

dependencies {
    api(project(":tidepool-model"))
    implementation("org.apache.commons:commons-csv:$commonsCsvVersion")
}
Groovy DSL: build.gradle (root project)
group = 'io.tidepool'
version = '1.0.0'

ext {
    commonsCsvVersion = '1.14.1'
    picocliVersion = '4.7.7'
}
Groovy DSL: tidepool-engine/build.gradle
plugins {
    id 'java-library'
}

dependencies {
    api project(':tidepool-model')
    implementation "org.apache.commons:commons-csv:${rootProject.ext.commonsCsvVersion}"
}

Both versions printed the same tree for the command line application:

$ ./gradlew :tidepool-cli:dependencies --configuration runtimeClasspath
...
runtimeClasspath - Runtime classpath of source set 'main'.
+--- project ':tidepool-engine'
|    +--- project ':tidepool-model'
|    \--- org.apache.commons:commons-csv:1.14.1
|         +--- commons-io:commons-io:2.20.0
|         \--- commons-codec:commons-codec:1.19.0
\--- info.picocli:picocli:4.7.7

The ext approach has three weaknesses that a catalog removes. A version is a bare String property, so nothing says which coordinates it belongs to and a typo in the property name is found only when the script runs; there is no name for a library, so the group and artifact are retyped in every project; and an IDE cannot offer completion or navigation for it. A catalog declares the whole coordinate once, gives it a name, and generates a typed accessor for the name. The Gradle best practices page for dependencies says it plainly: define versions in a catalog instead of in project.ext, constants or local variables.

A catalog is a declaration device and nothing else. It does not change how Gradle resolves the graph: a version in the catalog is an ordinary requirement of the project that uses it, exactly as if the string had been typed in the build script, and the resolved version can still be higher (conflict resolution) or different (a platform, a constraint, a rule). Consumers of the project cannot see whether a dependency came from a catalog. The section on platforms below shows this on the tidepool build.

History

Release Change

Gradle 7.0 (2021-04-09)

Version catalogs appear as an experimental feature, switched on in settings with enableFeaturePreview("VERSION_CATALOGS"): the file gradle/libs.versions.toml, bundles, sharing between builds and the Settings API.

Gradle 7.2

Still a feature preview. Aliases can now have sub-aliases (jackson and jackson-xml side by side) and a [plugins] table lets the plugins {} block use alias(libs.plugins.name).

Gradle 7.4 (2022-02-08)

Version catalogs are stable.

Gradle 8.1

Plugin aliases in the plugins {} block are no longer shown as errors in the Kotlin script editor of IntelliJ IDEA and Android Studio. Accessing libraries or bundles in the plugins {} block is deprecated.

Gradle 8.5

The gradle init task generates projects that use a catalog in gradle/libs.versions.toml; the versionCatalogs extension is available in precompiled Kotlin script plugins; the Javadoc of the generated accessors shows the version.

Gradle 8.7

The TOML parser is upgraded and is now compliant with version 1.0.0 of the TOML specification.

Gradle 8.8

A plugin alias without a version is explicitly allowed.

Gradle 9.0.0 (2025-07-31)

The deprecated use of libraries and bundles in the plugins {} block of Kotlin DSL scripts is removed (the build fails).

The book Gradle in Action was written for Gradle 1.7 and predates catalogs by eight years: its builds keep versions in ext or in plain local variables and declare them with the compile and testCompile configurations, which were removed in Gradle 7.0 (see Versions, Compatibility & Release History). Translate the idea (one place for versions) into a catalog and the configurations into implementation and testImplementation.

The tidepool catalog

The catalog file lives in the gradle directory of the root build, with exactly the name libs.versions.toml. Gradle imports that file automatically, under the catalog name libs, so nothing in settings.gradle(.kts) mentions it. The file below is the one used on the whole page. It is shown as plain text because the syntax highlighter has no TOML grammar:

gradle/libs.versions.toml (the same for both DSLs)
[versions]
assertj = "3.27.7"
commons-csv = "1.14.1"
junit = "6.1.3"
picocli = "4.7.7"

[libraries]
assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertj" }
commons-csv = { module = "org.apache.commons:commons-csv", version.ref = "commons-csv" }
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" }
junit-launcher = { module = "org.junit.platform:junit-platform-launcher" }
picocli = { module = "info.picocli:picocli", version.ref = "picocli" }

[bundles]
testing = ["junit-jupiter", "assertj-core"]

[plugins]
versions = { id = "io.github.ben-manes.versions", version = "0.66.0" }

The settings file only says where repositories come from. Forbidding repositories in the projects is what makes the catalog and the repositories the two things you have to look at to know where a dependency comes from (see Repositories & Content Filtering):

Kotlin DSL: settings.gradle.kts
dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
}

rootProject.name = "tidepool"

include("tidepool-model", "tidepool-engine", "tidepool-cli")
Groovy DSL: settings.gradle
dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
}

rootProject.name = 'tidepool'

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

The root project applies one plugin from the catalog, and the three projects use libraries and the bundle:

Kotlin DSL: build.gradle.kts (root project)
plugins {
    alias(libs.plugins.versions)
}

group = "io.tidepool"
version = "1.0.0"
Kotlin DSL: tidepool-model/build.gradle.kts
plugins {
    `java-library`
}

dependencies {
    testImplementation(platform(libs.junit.bom))
    testImplementation(libs.bundles.testing)
    testRuntimeOnly(libs.junit.launcher)
}

tasks.test {
    useJUnitPlatform()
}
Kotlin DSL: tidepool-engine/build.gradle.kts
plugins {
    `java-library`
}

dependencies {
    api(project(":tidepool-model"))
    implementation(libs.commons.csv)
}
Kotlin DSL: tidepool-cli/build.gradle.kts
plugins {
    application
}

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

application {
    mainClass = "io.tidepool.cli.Main"
}
Groovy DSL: build.gradle (root project)
plugins {
    alias(libs.plugins.versions)
}

group = 'io.tidepool'
version = '1.0.0'
Groovy DSL: tidepool-model/build.gradle
plugins {
    id 'java-library'
}

dependencies {
    testImplementation platform(libs.junit.bom)
    testImplementation libs.bundles.testing
    testRuntimeOnly libs.junit.launcher
}

tasks.named('test') {
    useJUnitPlatform()
}
Groovy DSL: tidepool-engine/build.gradle
plugins {
    id 'java-library'
}

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

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

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

No version number appears in any build script. Both copies built and ran the JUnit test of the model project (one test, passed), and the second run was served from the configuration cache:

$ ./gradlew build
> Task :tidepool-model:compileJava
> Task :tidepool-model:jar
> Task :tidepool-engine:compileJava
> Task :tidepool-engine:jar
> Task :tidepool-cli:compileJava
> Task :tidepool-cli:startScripts
> Task :tidepool-cli:distZip
...
> Task :tidepool-model:test
> Task :tidepool-model:check
> Task :tidepool-model:build

BUILD SUCCESSFUL in 1s
11 actionable tasks: 11 executed
Configuration cache entry stored.

$ ./gradlew build

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

The resolved graph of the model project’s test classpath shows what the catalog did not decide. junit-jupiter has no version in the catalog; the version comes from the JUnit BOM that the project imports with platform(libs.junit.bom), and the dependency tree shows it as junit-jupiter -> 6.1.3:

$ ./gradlew :tidepool-model:dependencies --configuration testRuntimeClasspath
...
testRuntimeClasspath - Runtime classpath of source set 'test'.
+--- org.junit:junit-bom:6.1.3
|    +--- org.junit.jupiter:junit-jupiter:6.1.3 (c)
|    +--- org.junit.platform:junit-platform-launcher:6.1.3 (c)
|    +--- org.junit.jupiter:junit-jupiter-engine:6.1.3 (c)
|    +--- org.junit.jupiter:junit-jupiter-api:6.1.3 (c)
|    +--- org.junit.jupiter:junit-jupiter-params:6.1.3 (c)
|    +--- org.junit.platform:junit-platform-engine:6.1.3 (c)
|    \--- org.junit.platform:junit-platform-commons:6.1.3 (c)
+--- org.junit.jupiter:junit-jupiter -> 6.1.3
|    +--- org.junit.jupiter:junit-jupiter-engine:6.1.3
...

If the BOM line is removed, the version-less alias has nothing to take a version from, and the failure message says so with a trailing colon and nothing after it (run with the configuration cache off, because with it on the failure is wrapped in a configuration cache message):

$ ./gradlew --no-configuration-cache :tidepool-model:compileTestJava
FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':tidepool-model:compileTestJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').
> Could not resolve all files for configuration ':tidepool-model:testCompileClasspath'.
   > Could not find org.junit.jupiter:junit-jupiter:.
     Required by:
         project ':tidepool-model'

The same example is configuration cache compatible: the catalog accessors are providers that Gradle serialises like any other dependency notation, and every run above after the first said Configuration cache entry reused.

flowchart LR T["gradle/libs.versions.toml (imported automatically as libs)"] --> S["settings evaluation"] A["versionCatalogs block in settings.gradle(.kts): from, version, library, plugin, bundle"] --> S P["published catalog: from group:artifact:version"] --> S S --> G["generated accessor classes (LibrariesForLibs and one per catalog)"] G --> X["extension libs on every project"] X --> D["dependencies: libs.commons.csv, libs.bundles.testing"] X --> L["plugins: alias(libs.plugins.versions)"] X --> V["versions: libs.versions.picocli.get()"]

The TOML format

The file has four tables. Everything is optional; an alias in one table never conflicts with the same alias in another.

Table Purpose

[versions]

Named versions that libraries and plugins refer to with version.ref. A version can be a plain string (a required version) or a rich version.

[libraries]

Aliases for coordinates: group, name and (optionally) a version.

[bundles]

Aliases for lists of library aliases, so that a group that always travels together is declared with one name.

[plugins]

Aliases for a plugin id and its version.

Versions

[versions]
other-lib = "5.5.0"                                      # a required version
my-lib = { strictly = "[1.0, 2.0[", prefer = "1.2" }     # a rich version

The members of a version table are require, strictly, prefer, reject (a list of rejected versions) and rejectAll (a boolean). Their meaning is the one of rich versions in a build script, described on Platforms, Constraints & Rich Versions. The catalog is only a way to write the same thing down. With the catalog changed to

[versions]
commons-csv = { strictly = "[1.10, 2.0[", prefer = "1.13.0", reject = ["1.12.0"] }

and nothing else touched, the engine project resolves the preferred version, and the tree prints the whole rich version before the arrow (the same in both DSLs):

$ ./gradlew :tidepool-engine:dependencies --configuration runtimeClasspath
...
runtimeClasspath - Runtime classpath of source set 'main'.
+--- project ':tidepool-model'
\--- org.apache.commons:commons-csv:{strictly [1.10, 2.0[; prefer 1.13.0; reject 1.12.0} -> 1.13.0
     +--- commons-io:commons-io:2.18.0
     \--- commons-codec:commons-codec:1.17.2

The transitive versions changed with the library (commons-io 2.18.0 instead of 2.20.0), which is the reason a catalog entry is a requirement and not a pin; pinning every transitive version is the job of dependency locking (see Dependency Locking, Verification & Caching).

Libraries

A library is a string with the coordinates, or a table. All the forms below are valid; the first column is what the manual shows:

Declaration Meaning

my-lib = "com.mycompany:mylib:1.4"

A string is group, name and version.

my-lib-no-version.module = "com.mycompany:mylib"

A dotted key (a TOML shortcut for a table) with no version; the version comes from somewhere else (a platform).

my-other-lib = { module = "com.mycompany:other", version = "1.4" }

module is group and name in one string; version is a literal version.

my-other-lib2 = { group = "com.mycompany", name = "alternate", version = "1.4" }

Group and name written separately.

mylib-full-format = { group = "com.mycompany", name = "alternate", version = { require = "1.4" } }

The version is itself a table with require, strictly, prefer, reject or rejectAll.

my-lib = { group = "com.mycompany", name = "mylib", version.ref = "some" }

version.ref names an entry of [versions], so several libraries share one version.

What a library entry cannot say matters as much. Version catalogs capture only the coordinates and a version: classifiers, artifact types, exclusion rules and capability requirements are not supported in the TOML file, and an unknown key is a failure (see the problems below). The workarounds are in the section on classifiers.

Bundles

[libraries]
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" }
assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertj" }

[bundles]
testing = ["junit-jupiter", "assertj-core"]

A bundle is used wherever a dependency is, and adds every member to that one configuration: testImplementation(libs.bundles.testing). A bundle cannot say that one member is an api dependency and another runtimeOnly; the tidepool catalog therefore keeps junit-launcher out of the bundle, since it belongs to testRuntimeOnly. A bundle can refer only to aliases of the same catalog; an alias that does not exist is a failure.

Plugins

[plugins]
versions = { id = "io.github.ben-manes.versions", version = "0.66.0" }

A plugin alias is applied with alias(...) in a plugins {} block, with the same limitations as a version in that block (the plugin is resolved from the plugin repositories before the build script runs). The versions plugin of the example is the Gradle Versions Plugin, which reports dependency updates and is the plugin that the manual itself uses in its examples. With the catalog in place, dependencyUpdates lists what has a newer release, for the root project and for every project (excerpt of the Kotlin DSL run; the Groovy DSL run printed the same report; the report file is written under build):

$ ./gradlew dependencyUpdates
...
> Task :dependencyUpdates

------------------------------------------------------------
: Project Dependency Updates (report to plain text file)
------------------------------------------------------------

The following dependencies are using the latest milestone version:
 - info.picocli:picocli:4.7.7
 - io.github.ben-manes.versions:io.github.ben-manes.versions.gradle.plugin:0.66.0
 - org.apache.commons:commons-csv:1.14.1
 - org.junit:junit-bom:6.1.3
 - org.junit.jupiter:junit-jupiter:6.1.3
     constrained by the platform org.junit:junit-bom
 - org.junit.platform:junit-platform-launcher:6.1.3
     constrained by the platform org.junit:junit-bom

The following dependencies have later milestone versions:
 - org.assertj:assertj-core [3.27.7 -> 4.0.0-M1]
     https://assertj.github.io/doc/#assertj-core

Gradle release-candidate updates:
 - Gradle: [9.8.1: UP-TO-DATE]
...
BUILD SUCCESSFUL in 2s
5 actionable tasks: 5 executed
Configuration cache entry stored.

The plugin was published under a new id, and the run with the old one (com.github.ben-manes.versions) printed The com.github.ben-manes.versions plugin id is deprecated; apply io.github.ben-manes.versions instead. A rename like that is a one-line change in the catalog, not a search through the build scripts. Source of the plugin: Gradle Plugin Portal: io.github.ben-manes.versions.

Two rules about plugin aliases come straight from the manual and from the release notes:

  • A plugin from a catalog can be used in a plugins {} block of a build script, but not in the plugins {} block of the settings file or a settings plugin. In the Kotlin DSL the attempt fails to compile with Unresolved reference 'libs', because there is no catalog yet while settings are being evaluated.

  • Only versions and plugins of a catalog can be used in the plugins {} block. A library or a bundle there was deprecated in Gradle 8.1 and has been an error since Gradle 9.0.0. Reproduced here on Gradle 9.8.1 with id(libs.picocli.get().module.toString()) in the model project:

$ ./gradlew help
FAILURE: Build failed with an exception.

* Where:
Build file '/work/tidepool/tidepool-model/build.gradle.kts' line: 3

* What went wrong:
Accessing libraries or bundles from version catalogs in the plugins block is not allowed. Only use versions or plugins from catalogs in the plugins block.

Aliases and type-safe accessors

When the settings are evaluated, Gradle reads the catalog and generates one class per catalog (org.gradle.accessors.dm.LibrariesForLibs for libs), registers an instance as the extension libs on every project, and exposes the catalog in four namespaces:

Accessor Type of the value Example from the tidepool catalog

libs.<alias>

A provider of a MinimalExternalModuleDependency

libs.commons.csv, libs.picocli

libs.versions.<alias>

A provider of the version as a String

libs.versions.picocli.get() is 4.7.7

libs.bundles.<alias>

A provider of an ExternalModuleDependencyBundle

libs.bundles.testing

libs.plugins.<alias>

A provider of a PluginDependency (id and version)

libs.plugins.versions

Because the accessors are providers, they are lazy and safe for the configuration cache, and they can be passed to any API that takes a provider of a dependency (dependencies.add(...), constraints, testImplementation(...)) without calling get().

How an alias becomes an accessor

An alias is made of identifiers separated by a dash or an underscore. Gradle normalises the separators to dots and each dot starts a nested group, so that an IDE can offer completion level by level:

Alias in the file Accessor Notes

commons-csv

libs.commons.csv

Dashes become dots.

my_lib

libs.my.lib

Underscores do too; Gradle’s own guidance prefers dashes.

picocli

libs.picocli

A single segment stays flat.

groovyCore

libs.groovyCore

camelCase avoids the sub-group.

groovyJson-core

libs.groovyJson.core

camelCase and a dash mix.

The catalog API normalises the same way. The task below (registered in the root project) lists what the catalog contains through the VersionCatalogsExtension API, with the aliases printed in their normalised, dotted form:

Kotlin DSL: build.gradle.kts (root project)
tasks.register("printCatalog") {
    val libs = versionCatalogs.named("libs")
    val lines = buildList {
        add("versions: " + libs.versionAliases.sorted().joinToString())
        libs.libraryAliases.sorted().forEach { add("library " + it + " = " + libs.findLibrary(it).get().get()) }
        libs.bundleAliases.sorted().forEach { add("bundle " + it + " = " + libs.findBundle(it).get().get()) }
        libs.pluginAliases.sorted().forEach { add("plugin " + it + " = " + libs.findPlugin(it).get().get()) }
    }
    doLast { lines.forEach { println(it) } }
}
Groovy DSL: build.gradle (root project)
def catalog = extensions.getByType(VersionCatalogsExtension).named('libs')

tasks.register('printCatalog') {
    def lines = []
    lines << 'versions: ' + catalog.versionAliases.sort().join(', ')
    catalog.libraryAliases.sort().each { lines << 'library ' + it + ' = ' + catalog.findLibrary(it).get().get() }
    catalog.bundleAliases.sort().each { lines << 'bundle ' + it + ' = ' + catalog.findBundle(it).get().get() }
    catalog.pluginAliases.sort().each { lines << 'plugin ' + it + ' = ' + catalog.findPlugin(it).get().get() }
    doLast { lines.each { println it } }
}

Both printed the same lines. Note the dotted aliases (commons.csv, even for the version) and the version-less entries:

$ ./gradlew -q printCatalog
versions: assertj, commons.csv, junit, picocli
library assertj.core = org.assertj:assertj-core:3.27.7
library commons.csv = org.apache.commons:commons-csv:1.14.1
library junit.bom = org.junit:junit-bom:6.1.3
library junit.jupiter = org.junit.jupiter:junit-jupiter
library junit.launcher = org.junit.platform:junit-platform-launcher
library picocli = info.picocli:picocli:4.7.7
bundle testing = [org.junit.jupiter:junit-jupiter, org.assertj:assertj-core:3.27.7]
plugin versions = io.github.ben-manes.versions:0.66.0

The task reads the catalog while it is configured and keeps only strings for the execution phase, so it is configuration cache compatible; the second run said Configuration cache entry reused. A task that kept the extension and called findLibrary in doLast would capture the project, which the configuration cache does not allow (see Configuration Cache & Isolated Projects). The same API is what a convention plugin uses to read the catalog of the project it is applied to, shown on Convention Plugins & Sharing Build Logic.

Naming rules and the errors for breaking them

An alias must match the regular expression below. Experiments with the tidepool catalog show what Gradle does with names that do not:

[a-z]([a-zA-Z0-9_.\-])+
Alias added to the catalog Result on Gradle 9.8.1

my_lib

Accepted, becomes libs.my.lib.

groovyCore

Accepted, becomes libs.groovyCore.

Upper or 1abc

Invalid alias notation: library aliases must match the following regular expression.

Alias!

TOML syntax error: Unexpected '!', expected = (in a TOML file the character is rejected by the TOML parser before Gradle’s alias check).

my.lib

TOML syntax error: Dots (.) in TOML keys create nested entries and cannot be used in alias names.

extensions

Reserved alias name: extensions and convention are reserved.

versions-dependency

Reserved alias name: Prefix for dependency shouldn't be equal to 'versions'. The first segment of a library cannot be bundles, plugins or versions, because those are the namespaces above.

The full text of the reserved alias failure, as printed by the build:

$ ./gradlew help
FAILURE: Build failed with an exception.

* What went wrong:
org.gradle.api.InvalidUserDataException
> Reserved alias name
    In version catalog libs, alias 'extensions' is a reserved alias
      Alias 'extensions' is a reserved name in Gradle which prevents generation of accessors.
      For more information, please refer to https://docs.gradle.org/9.8.1/userguide/version_catalog_problems.html#reserved_alias_name in the Gradle documentation.
      Possible solution: Use a different alias which doesn't contain any of 'convention' or 'extensions'.

Two spellings that differ only in separators collide, because they map to the same accessor method. This is a failure of the whole catalog, not a warning:

$ ./gradlew help
FAILURE: Build failed with an exception.

* What went wrong:
org.gradle.api.InvalidUserDataException
> Accessor name clash
    In version catalog libs, library aliases some.alias and someAlias are mapped to the same accessor name getSomeAlias()
      A name clash was detected
      For more information, please refer to https://docs.gradle.org/9.8.1/userguide/version_catalog_problems.html#accessor_name_clash in the Gradle documentation.
      Possible solution: Use a different alias for some.alias and someAlias.

The failing catalog had someAlias and some-alias.

Sub-groups: libs.versions.jackson

A sub-group appears whenever one alias is the prefix of another. With these two versions in the catalog

[versions]
jackson = "2.17.1"
jackson-databind = "2.17.2"

libs.versions.jackson is no longer a version but the group that holds databind. Its get() does not exist, and the way back to the version of the parent alias is asProvider(). Both DSLs gave the same output with asProvider(), and failed without it:

Kotlin DSL: build.gradle.kts (root project)
tasks.register("showJackson") {
    val parent = libs.versions.jackson.asProvider().get()
    val child = libs.versions.jackson.databind.get()
    doLast { println("jackson=" + parent + " jackson-databind=" + child) }
}
Groovy DSL: build.gradle (root project)
tasks.register('showJackson') {
    def parent = libs.versions.jackson.asProvider().get()
    def child = libs.versions.jackson.databind.get()
    doLast { println 'jackson=' + parent + ' jackson-databind=' + child }
}
$ ./gradlew -q showJackson
jackson=2.17.1 jackson-databind=2.17.2

Without asProvider() the Kotlin DSL does not compile (Unresolved reference on get), and the Groovy DSL fails with the dynamic version of the same error:

> Could not create task ':showJackson'.
   > No signature of method: org.gradle.accessors.dm.LibrariesForLibs$JacksonVersionAccessors.get() is applicable for argument types: () values: []

The manual’s advice is to avoid sub-group accessors by naming with camelCase when you control the names, and to use asProvider() when you consume a published catalog whose names you cannot change.

A typo is found when the script is compiled

Misspelling an alias is an error of the build script, not of the catalog. The Kotlin DSL reports it at compile time, with the position, and the Groovy DSL when the script is evaluated:

$ ./gradlew help     # Kotlin DSL: implementation(libs.picocl)
> Configure project :tidepool-cli
e: file:///work/tidepool/tidepool-cli/build.gradle.kts:7:25: Unresolved reference 'picocl'.
FAILURE: Build failed with an exception.

* Where:
Build file '/work/tidepool/tidepool-cli/build.gradle.kts' line: 7

* What went wrong:
Script compilation error:
  Line 7:     implementation(libs.picocl)
                                  ^ Unresolved reference 'picocl'.

1 error
$ ./gradlew help     # Groovy DSL: implementation libs.picocl
FAILURE: Build failed with an exception.

* Where:
Build file '/work/tidepool/tidepool-cli/build.gradle' line: 7

* What went wrong:
A problem occurred evaluating project ':tidepool-cli'.
> Could not get unknown property 'picocl' for extension 'libs' of type org.gradle.accessors.dm.LibrariesForLibs.

Passing the provider, not its value

libs.picocli is a Provider<MinimalExternalModuleDependency>. In a dependency declaration libs.picocli and libs.picocli.get() are identical, but in other APIs (substitution rules, constraints, anything that takes a dependency notation) pass the provider: Gradle converts it as needed, while the unwrapped MinimalExternalModuleDependency is a ModuleVersionSelector, which some resolution APIs do not accept where they expect a ModuleComponentSelector.

Declaring catalogs in the settings file

The TOML file is the declarative form. The same model is available in settings.gradle(.kts) through the versionCatalogs container of dependencyResolutionManagement, which is the only way to do four things: use another file name or location, register more than one catalog, take a catalog from a repository, and override or add entries.

Kotlin DSL: settings.gradle.kts
dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
    versionCatalogs {
        create("libs") {
            from(files("gradle/tidepool.versions.toml"))
            version("picocli", "4.7.6")
        }
        create("testLibs") {
            from(files("gradle/test.versions.toml"))
        }
        create("tools") {
            version("errorprone", "2.28.0")
            library("errorprone-annotations", "com.google.errorprone", "error_prone_annotations").versionRef("errorprone")
            library("jspecify", "org.jspecify:jspecify:1.0.0")
        }
    }
}

rootProject.name = "tidepool"

include("tidepool-model", "tidepool-engine", "tidepool-cli")
Groovy DSL: settings.gradle
dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
    versionCatalogs {
        libs {
            from(files('gradle/tidepool.versions.toml'))
            version('picocli', '4.7.6')
        }
        testLibs {
            from(files('gradle/test.versions.toml'))
        }
        tools {
            version('errorprone', '2.28.0')
            library('errorprone-annotations', 'com.google.errorprone', 'error_prone_annotations').versionRef('errorprone')
            library('jspecify', 'org.jspecify:jspecify:1.0.0')
        }
    }
}

rootProject.name = 'tidepool'

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

The catalog file was renamed from libs.versions.toml to tidepool.versions.toml (see below why), and gradle/test.versions.toml holds the test libraries:

gradle/test.versions.toml
[versions]
junit = "6.1.3"

[libraries]
bom = { module = "org.junit:junit-bom", version.ref = "junit" }
jupiter = { module = "org.junit.jupiter:junit-jupiter" }
launcher = { module = "org.junit.platform:junit-platform-launcher" }
assertj = "org.assertj:assertj-core:3.27.7"

The model project then uses all three extensions, one per catalog:

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

dependencies {
    compileOnly(tools.errorprone.annotations)
    compileOnly(tools.jspecify)
    testImplementation(platform(testLibs.bom))
    testImplementation(testLibs.jupiter)
    testImplementation(testLibs.assertj)
    testRuntimeOnly(testLibs.launcher)
}

tasks.test {
    useJUnitPlatform()
}
Groovy DSL: tidepool-model/build.gradle
plugins {
    id 'java-library'
}

dependencies {
    compileOnly tools.errorprone.annotations
    compileOnly tools.jspecify
    testImplementation platform(testLibs.bom)
    testImplementation testLibs.jupiter
    testImplementation testLibs.assertj
    testRuntimeOnly testLibs.launcher
}

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

Both DSLs built the three projects, and the command line application resolved the overridden version while the model’s compile classpath got the programmatic entries (configuration cache compatible, entry stored and reused):

$ ./gradlew :tidepool-cli:dependencies --configuration runtimeClasspath
...
runtimeClasspath - Runtime classpath of source set 'main'.
+--- project ':tidepool-engine'
|    +--- project ':tidepool-model'
|    \--- org.apache.commons:commons-csv:1.14.1
|         +--- commons-io:commons-io:2.20.0
|         \--- commons-codec:commons-codec:1.19.0
\--- info.picocli:picocli:4.7.6

$ ./gradlew :tidepool-model:dependencies --configuration compileClasspath
...
compileClasspath - Compile classpath for source set 'main'.
+--- com.google.errorprone:error_prone_annotations:2.28.0
\--- org.jspecify:jspecify:1.0.0

The rules this example shows, with the reproduction of each:

  • from can be called once per catalog. The default file gradle/libs.versions.toml is imported automatically, so declaring create("libs") { from(files("gradle/libs.versions.toml")) } calls from a second time and fails in both DSLs. That is the reason the example moved the file to another name. The same applies to a catalog built from two files. The failure:

    $ ./gradlew help
    FAILURE: Build failed with an exception.
    
    * What went wrong:
    Multiple 'from' invocations
      In version catalog libs, you can only call the 'from' method a single time
        The method was called more than once
        For more information, please refer to https://docs.gradle.org/9.8.1/userguide/version_catalog_problems.html#too_many_import_invocation in the Gradle documentation.
        Possible solution: Remove further usages of the method call.
  • from takes exactly one file. Importing a missing file and importing two files at once each fail with their own message:

    > Import of external catalog file failed
        In version catalog tools, import of external catalog file failed
          File '/work/tidepool/gradle/tools.versions.toml' doesn't exist
          ...
          Possible solution: Make sure that the catalog file 'tools.versions.toml' exists before importing it.
    
    > Importing multiple files is not supported
        In version catalog tools, importing multiple files is not supported
          The import consists of multiple files
          ...
          Possible solution: Only import a single file.
  • Calls after from add or override entries. version("picocli", "4.7.6") replaced the version that the TOML file declared, and every library that refers to it with version.ref followed. An override affects only what is imported and what is written in dependency declarations: the resolved version can still differ because of conflict resolution.

  • The programmatic API has the same four verbs as the file: version, library, bundle and plugin. A library declared without a version in its notation is finished by versionRef, version or withoutVersion; one that is not finished is the problem alias_not_finished, an alias that was created but not registered.

  • Every catalog is its own extension on all projects. Pick a name that cannot collide with an existing extension (the manual suggests a name that ends with Libs, as testLibs above), and do not use libs for a programmatic catalog when a gradle/libs.versions.toml exists.

Changing the name of the default catalog

The default catalog is called libs. If that name is taken (for example by an extension of a plugin), rename it for the whole build:

Kotlin DSL: settings.gradle.kts
dependencyResolutionManagement {
    defaultLibrariesExtensionName = "tidepoolLibs"
}
Groovy DSL: settings.gradle
dependencyResolutionManagement {
    defaultLibrariesExtensionName = 'tidepoolLibs'
}

With the setting in place gradle/libs.versions.toml is still imported, but as tidepoolLibs, and the scripts must use the new name (implementation(tidepoolLibs.picocli)). Scripts that still said libs failed with Unresolved reference 'libs' (Kotlin DSL) and with a script compilation problem (Groovy DSL); after renaming them, both DSLs built and printed the same graph as before.

More than one catalog, or one

The manual is sceptical about splitting. A single catalog centralises every coordinate, avoids version duplication and gives every project the same accessors; use alias prefixes to group entries (junit-..., mockito-...) inside it. Split only when you need a boundary the file itself cannot express:

  • Auditing what ships. When you must distinguish the dependencies of a published distribution from those used only for tests or code generation, separate catalogs make the difference visible where the dependency is declared.

  • Independent build logic. Plugins applied inside build-logic or buildSrc may need their own catalog, resolved before the main build’s catalog exists.

  • Organisational sharing. A company-wide catalog is published as an artifact and imported by many repositories, while each repository keeps a local catalog for its own dependencies.

Sharing one version between catalogs

Each TOML file is self-contained. There is no include and no version.ref that reaches into another file, and from is allowed once, so two files cannot be merged. If a version must be the same in two catalogs, the manual describes three workarounds: declare it in both files and add a check that they agree; keep the shared versions in a properties file that settings.gradle(.kts) injects into each catalog (what the Gradle project itself does); or publish a base catalog and layer local overrides on top (the section on publishing below). The second workaround, run on tidepool with gradle/shared-versions.properties holding commons-csv=1.14.1 and picocli=4.7.7 and those two versions removed from the TOML file:

Kotlin DSL: settings.gradle.kts
val shared = java.util.Properties().apply {
    file("gradle/shared-versions.properties").inputStream().use { load(it) }
}

dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
    versionCatalogs {
        create("libs") {
            from(files("gradle/tidepool.versions.toml"))
            version("commons-csv", shared.getProperty("commons-csv"))
            version("picocli", shared.getProperty("picocli"))
        }
    }
}

rootProject.name = "tidepool"

include("tidepool-model", "tidepool-engine", "tidepool-cli")
Groovy DSL: settings.gradle
def shared = new Properties()
file('gradle/shared-versions.properties').withInputStream { shared.load(it) }

dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
    versionCatalogs {
        libs {
            from(files('gradle/tidepool.versions.toml'))
            version('commons-csv', shared.getProperty('commons-csv'))
            version('picocli', shared.getProperty('picocli'))
        }
    }
}

rootProject.name = 'tidepool'

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

The TOML file still refers to commons-csv and picocli with version.ref; the versions now come from the properties file. Both DSLs built the project and resolved commons-csv:1.14.1 and picocli:4.7.7. The configuration cache tracks the properties file: after changing picocli=4.7.7 to picocli=4.7.6, the next run printed Calculating task graph as configuration cache cannot be reused because file 'gradle/shared-versions.properties' has changed. and resolved 4.7.6, and the run after that reused the new entry.

Catalogs in buildSrc and build-logic

The build logic of a build is compiled in a separate build (buildSrc or an included build such as build-logic), and that build has its own settings and therefore its own catalogs. It does not inherit the catalog of the main build. Using libs in buildSrc/build.gradle(.kts) without doing anything fails:

Kotlin DSL: buildSrc/build.gradle.kts
plugins {
    `kotlin-dsl`
}

repositories {
    mavenCentral()
}

dependencies {
    implementation(libs.commons.csv)
}
Groovy DSL: buildSrc/build.gradle
plugins {
    id 'groovy-gradle-plugin'
}

repositories {
    mavenCentral()
}

dependencies {
    implementation libs.commons.csv
}
$ ./gradlew help     # Kotlin DSL
> Configure project :buildSrc
e: file:///work/tidepool/buildSrc/build.gradle.kts:10:20: Unresolved reference 'libs'.
FAILURE: Build failed with an exception.

* Where:
Build file '/work/tidepool/buildSrc/build.gradle.kts' line: 10

* What went wrong:
Script compilation error:
  Line 10:     implementation(libs.commons.csv)
                              ^ Unresolved reference 'libs'.

1 error

$ ./gradlew help     # Groovy DSL
...
* What went wrong:
A problem occurred evaluating project ':buildSrc'.
> Could not get unknown property 'libs' for object of type org.gradle.api.internal.artifacts.dsl.dependencies.DefaultDependencyHandler.

The fix is to import the file of the main build in the settings of the build-logic build, with a path that points up one level:

Kotlin DSL: buildSrc/settings.gradle.kts
dependencyResolutionManagement {
    versionCatalogs {
        create("libs") {
            from(files("../gradle/libs.versions.toml"))
        }
    }
}
Groovy DSL: buildSrc/settings.gradle
dependencyResolutionManagement {
    versionCatalogs {
        create('libs') {
            from(files('../gradle/libs.versions.toml'))
        }
    }
}

The libs here is a second libs, built from the same file, and the file buildSrc/gradle/libs.versions.toml does not exist, so the automatic import does not clash with the explicit one. With the import, both DSLs resolved the library in the buildSrc build:

$ ./gradlew :buildSrc:dependencies --configuration runtimeClasspath
...
Project ':buildSrc'
...
\--- org.apache.commons:commons-csv:1.14.1
     +--- commons-io:commons-io:2.20.0
     \--- commons-codec:commons-codec:1.19.0

A plugin from the catalog as a build-logic dependency

Convention plugins need the plugins they apply on the classpath of the build-logic project, as ordinary dependencies on the plugin’s marker artifact (id:id.gradle.plugin:version). The plugins {} block of the build-logic project cannot do that, and a precompiled script plugin cannot use alias(...) for external plugins. The catalog already holds the id and the version, so a small helper turns a plugin alias into the dependency notation. The included build below was run in both DSLs and the convention plugin tidepool.updates applies the Versions plugin:

Kotlin DSL: settings.gradle.kts
pluginManagement {
    includeBuild("build-logic")
}

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

rootProject.name = "tidepool"

include("tidepool-model", "tidepool-engine", "tidepool-cli")
Kotlin DSL: build-logic/settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        gradlePluginPortal()
    }
    versionCatalogs {
        create("libs") {
            from(files("../gradle/libs.versions.toml"))
        }
    }
}

rootProject.name = "build-logic"
Kotlin DSL: build-logic/build.gradle.kts
plugins {
    `kotlin-dsl`
}

dependencies {
    implementation(plugin(libs.plugins.versions))
}

// turns a plugin alias of the catalog into the dependency notation of the plugin's marker artifact
fun plugin(plugin: Provider<PluginDependency>) =
    plugin.map { "${it.pluginId}:${it.pluginId}.gradle.plugin:${it.version}" }
Kotlin DSL: build-logic/src/main/kotlin/tidepool.updates.gradle.kts
plugins {
    id("io.github.ben-manes.versions")
}
Kotlin DSL: build.gradle.kts (root project)
plugins {
    id("tidepool.updates")
}

group = "io.tidepool"
version = "1.0.0"
Groovy DSL: settings.gradle
pluginManagement {
    includeBuild('build-logic')
}

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

rootProject.name = 'tidepool'

include 'tidepool-model', 'tidepool-engine', 'tidepool-cli'
Groovy DSL: build-logic/settings.gradle
dependencyResolutionManagement {
    repositories {
        gradlePluginPortal()
    }
    versionCatalogs {
        create('libs') {
            from(files('../gradle/libs.versions.toml'))
        }
    }
}

rootProject.name = 'build-logic'
Groovy DSL: build-logic/build.gradle
plugins {
    id 'groovy-gradle-plugin'
}

dependencies {
    implementation plugin(libs.plugins.versions)
}

// turns a plugin alias of the catalog into the dependency notation of the plugin's marker artifact
def plugin(Provider<PluginDependency> plugin) {
    return plugin.map { it.pluginId + ':' + it.pluginId + '.gradle.plugin:' + it.version }
}
Groovy DSL: build-logic/src/main/groovy/tidepool.updates.gradle
plugins {
    id 'io.github.ben-manes.versions'
}
Groovy DSL: build.gradle (root project)
plugins {
    id 'tidepool.updates'
}

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

The precompiled script plugin has no version in its plugins {} block, because the version is the one of the dependency, which in turn is the one in the catalog; dependencyUpdates is then registered in the root project by the convention plugin:

$ ./gradlew help --task dependencyUpdates
...
> Task :help
Detailed task information for dependencyUpdates

Path
     :dependencyUpdates (registered by plugin 'io.github.ben-manes.versions')

Type
     DependencyUpdatesTask (com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask)
...

Libraries (not plugins) of the catalog are read inside a precompiled script plugin with the VersionCatalogsExtension API, as in the java-conventions plugin on Convention Plugins & Sharing Build Logic; since Gradle 8.5 the Kotlin DSL offers the accessor versionCatalogs in such scripts. The plugins {} block of a precompiled script plugin cannot use the catalog at all. The page on composite builds explains why an included build shares no catalog with its parent by itself (see Composite Builds).

Publishing and importing a catalog

There are three ways to share a catalog between builds, in increasing effort: check the TOML file into a shared repository that all builds can see; write a settings plugin that declares the entries and publish it; or publish the catalog as an artifact with the version-catalog plugin. The last is the way for an organisation that distributes one catalog to many independent builds, and it is the one run here.

Publishing

Applying version-catalog adds a catalog extension, the task generateCatalogAsToml, and a software component versionCatalog that a publication attaches. The easiest source is the existing TOML file, which then stays the single source of truth:

Kotlin DSL: tidepool-catalog/build.gradle.kts
plugins {
    `version-catalog`
    `maven-publish`
}

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

catalog {
    versionCatalog {
        from(files("gradle/libs.versions.toml"))
    }
}

publishing {
    publications {
        create<MavenPublication>("catalog") {
            from(components["versionCatalog"])
        }
    }
    repositories {
        maven {
            name = "local"
            url = uri(layout.buildDirectory.dir("repo"))
        }
    }
}
Groovy DSL: tidepool-catalog/build.gradle
plugins {
    id 'version-catalog'
    id 'maven-publish'
}

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

catalog {
    versionCatalog {
        from(files('gradle/libs.versions.toml'))
    }
}

publishing {
    publications {
        catalog(MavenPublication) {
            from components.versionCatalog
        }
    }
    repositories {
        maven {
            name = 'local'
            url = uri(layout.buildDirectory.dir('repo'))
        }
    }
}

The settings file of that small build only names it (rootProject.name = "tidepool-catalog"), and gradle.properties turns the configuration cache on. Publishing worked in both DSLs and is configuration cache compatible (the second run reused the entry):

$ ./gradlew publish
Calculating task graph as no cached configuration is available for tasks: publish
> Task :generateCatalogAsToml
> Task :generatePomFileForCatalogPublication
> Task :generateMetadataFileForCatalogPublication
> Task :publishCatalogPublicationToLocalRepository
> Task :publish

BUILD SUCCESSFUL in 1s
4 actionable tasks: 4 executed
Configuration cache entry stored.

The repository got a POM, a Gradle module metadata file and the catalog itself as an artifact with the extension toml (the checksum files are left out of the listing):

build/repo/io/tidepool/tidepool-catalog/maven-metadata.xml
build/repo/io/tidepool/tidepool-catalog/1.0.0/tidepool-catalog-1.0.0.module
build/repo/io/tidepool/tidepool-catalog/1.0.0/tidepool-catalog-1.0.0.pom
build/repo/io/tidepool/tidepool-catalog/1.0.0/tidepool-catalog-1.0.0.toml

The POM has the packaging toml and a comment that tells Maven-only consumers that richer metadata exists. The module metadata describes one variant, versionCatalogElements, with the attributes org.gradle.category=platform and org.gradle.usage=version-catalog (see Variant-aware Resolution & Module Metadata). The published TOML is rewritten by Gradle in the expanded format, with a [metadata] header, one-line tables and the group and name split:

#
# This file has been generated by Gradle and is intended to be consumed by Gradle
#
[metadata]
format.version = "1.1"

[versions]
assertj = "3.27.7"
commons-csv = "1.14.1"
junit = "6.1.3"
picocli = "4.7.7"

[libraries]
assertj-core = {group = "org.assertj", name = "assertj-core", version.ref = "assertj" }
commons-csv = {group = "org.apache.commons", name = "commons-csv", version.ref = "commons-csv" }
junit-bom = {group = "org.junit", name = "junit-bom", version.ref = "junit" }
junit-jupiter = {group = "org.junit.jupiter", name = "junit-jupiter", version = "" }
junit-launcher = {group = "org.junit.platform", name = "junit-platform-launcher", version = "" }
picocli = {group = "info.picocli", name = "picocli", version.ref = "picocli" }

[bundles]
testing = ["junit-jupiter", "assertj-core"]

[plugins]
versions = {id = "io.github.ben-manes.versions", version = "0.66.0" }

A catalog can also be declared inside the catalog { versionCatalog { ... } } block with the same version, library, bundle and plugin verbs as in the settings (for example library("my-lib", "com.mycompany:mylib:1.2")), with no TOML file at all. Publishing to a remote repository only changes the repositories block (a Maven Central or internal repository with credentials from the environment) and is not run here.

Importing

The consumer imports the catalog in settings with ordinary dependency notation. Gradle downloads the TOML file from the repositories declared in dependencyResolutionManagement (so the repository that serves the catalog must be listed there), and any entry can be overridden afterwards:

Kotlin DSL: settings.gradle.kts
dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        maven {
            name = "catalogs"
            url = uri("../tidepool-catalog/build/repo")
        }
        mavenCentral()
    }
    versionCatalogs {
        create("libs") {
            from("io.tidepool:tidepool-catalog:1.0.0")
            version("picocli", "4.7.6")
        }
    }
}

rootProject.name = "tidepool"

include("tidepool-model", "tidepool-engine", "tidepool-cli")
Groovy DSL: settings.gradle
dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        maven {
            name = 'catalogs'
            url = uri('../tidepool-catalog/build/repo')
        }
        mavenCentral()
    }
    versionCatalogs {
        libs {
            from('io.tidepool:tidepool-catalog:1.0.0')
            version('picocli', '4.7.6')
        }
    }
}

rootProject.name = 'tidepool'

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

The consumer has no gradle/libs.versions.toml at all. Both DSLs built it, and the tree shows the published versions plus the local override:

$ ./gradlew build
...
BUILD SUCCESSFUL in 778ms
11 actionable tasks: 11 executed
Configuration cache entry stored.

$ ./gradlew :tidepool-cli:dependencies --configuration runtimeClasspath
...
runtimeClasspath - Runtime classpath of source set 'main'.
+--- project ':tidepool-engine'
|    +--- project ':tidepool-model'
|    \--- org.apache.commons:commons-csv:1.14.1
|         +--- commons-io:commons-io:2.20.0
|         \--- commons-codec:commons-codec:1.19.0
\--- info.picocli:picocli:4.7.6

If the repository that serves the catalog is not declared, the failure is a dependency-resolution failure of a configuration with the name incomingCatalogForLibs0, not a catalog message:

$ ./gradlew help
FAILURE: Build failed with an exception.

* What went wrong:
Could not resolve all artifacts for configuration 'incomingCatalogForLibs0'.
> Could not find io.tidepool:tidepool-catalog:1.0.0.
  Searched in the following locations:
    - https://repo.maven.apache.org/maven2/io/tidepool/tidepool-catalog/1.0.0/tidepool-catalog-1.0.0.pom
  Required by:
      Dependency Resolution Management
> If the artifact you are trying to retrieve can be found in the repository but without metadata in 'Maven POM' format, you need to adjust the 'metadataSources { ... }' of the repository declaration.

An imported catalog is a dependency like any other: it is resolved from the repositories of the settings, so pin it with an exact version (never a dynamic one), and for builds that must be reproducible see how downloaded artifacts are verified on Supply Chain Security and Dependency Locking, Verification & Caching. Overriding a version of an imported catalog (version("picocli", "4.7.6")) changes what the accessors return, not what conflict resolution does with it.

Catalogs and platforms

A catalog and a platform answer different questions. A catalog decides what you write; a platform decides what Gradle selects.

Version catalog Platform (java-platform, BOM)

Purpose

Names the coordinates and versions that a build declares, with type-safe accessors.

A module in the dependency graph that supplies or enforces versions for dependencies, including transitive ones.

Effect on resolution

None by itself: a version from the catalog is an ordinary requirement.

Constraints take part in conflict resolution and are propagated to consumers.

Visible to consumers of your library

No: they cannot tell that a dependency came from a catalog.

Yes: it is part of your published metadata.

Typical use

Share coordinates across the projects of one build, or across builds.

Align versions across modules and enforce them.

The tidepool build shows how little the catalog decides. A tidepool-platform project (the java-platform plugin) has a constraint on commons-csv, and the engine imports the platform next to a catalog entry:

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

dependencies {
    constraints {
        api("org.apache.commons:commons-csv:1.12.0")
    }
}
Kotlin DSL: tidepool-engine/build.gradle.kts
plugins {
    `java-library`
}

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

dependencies {
    constraints {
        api 'org.apache.commons:commons-csv:1.12.0'
    }
}
Groovy DSL: tidepool-engine/build.gradle
plugins {
    id 'java-library'
}

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

With the catalog still saying 1.14.1, the constraint (1.12.0) loses, because conflict resolution takes the highest requirement (both DSLs printed the same tree; (c) marks a constraint):

$ ./gradlew :tidepool-engine:dependencies --configuration runtimeClasspath
...
runtimeClasspath - Runtime classpath of source set 'main'.
+--- project ':tidepool-model'
+--- project ':tidepool-platform'
|    \--- org.apache.commons:commons-csv:1.12.0 -> 1.14.1 (c)
\--- org.apache.commons:commons-csv:1.14.1
     +--- commons-io:commons-io:2.20.0
     \--- commons-codec:commons-codec:1.19.0

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

If the platform constraint is made strict, the catalog’s 1.14.1 is outside the allowed range, and the result is not a silent win for either side but a resolution failure (both DSLs; excerpt):

Kotlin DSL: tidepool-platform/build.gradle.kts
dependencies {
    constraints {
        api("org.apache.commons:commons-csv") {
            version {
                strictly("1.12.0")
            }
        }
    }
}
Groovy DSL: tidepool-platform/build.gradle
dependencies {
    constraints {
        api('org.apache.commons:commons-csv') {
            version {
                strictly '1.12.0'
            }
        }
    }
}
$ ./gradlew :tidepool-engine:dependencies --configuration runtimeClasspath
...
+--- project ':tidepool-platform'
|    \--- org.apache.commons:commons-csv:{strictly 1.12.0} FAILED
\--- org.apache.commons:commons-csv:1.14.1 FAILED

$ ./gradlew :tidepool-engine:dependencyInsight --configuration runtimeClasspath --dependency commons-csv
org.apache.commons:commons-csv:1.12.0 (by constraint) FAILED
   Failures:
      - Could not resolve org.apache.commons:commons-csv:{strictly 1.12.0}.
          - Cannot find a version of 'org.apache.commons:commons-csv' that satisfies the version constraints:
               Dependency path: project ':tidepool-engine' (runtimeClasspath) --> 'org.apache.commons:commons-csv:1.14.1'
               Constraint path: project ':tidepool-engine' (runtimeClasspath) --> project ':tidepool-platform' (runtimeElements) --> 'org.apache.commons:commons-csv:{strictly 1.12.0}'
...

The pattern the manual recommends for using both is to give the catalog entry no version and let the platform decide, so that there is one place where the version is chosen: the catalog names the coordinates and the platform supplies the number. With the line commons-csv = { module = "org.apache.commons:commons-csv" } in the catalog (and the plain constraint again), the engine gets the platform’s version:

$ ./gradlew :tidepool-engine:dependencies --configuration runtimeClasspath
...
runtimeClasspath - Runtime classpath of source set 'main'.
+--- project ':tidepool-model'
+--- project ':tidepool-platform'
|    \--- org.apache.commons:commons-csv:1.12.0 (c)
\--- org.apache.commons:commons-csv -> 1.12.0
     +--- commons-io:commons-io:2.17.0
     \--- commons-codec:commons-codec:1.17.1

That build then fails to compile, which is a good reminder that downgrading is not free: the engine calls CSVFormat.Builder.get(), a method that Commons CSV 1.12.0 does not have (error: cannot find symbol, symbol method get(), location class Builder). The JUnit BOM in the tidepool catalog is the same idea in the other direction: junit-jupiter and junit-launcher are version-less aliases, and platform(libs.junit.bom) is the platform that gives them a version. Platforms, enforcedPlatform and the rich-version vocabulary are covered on Platforms, Constraints & Rich Versions.

Classifiers, artifact types and the TOML limits

The TOML file cannot express classifiers, artifact types (@aar, @zip), exclusion rules or capability requirements. The workaround is to declare these where the dependency is declared, using the catalog entry as the base. A classifier is added with variantOf:

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

val sources by configurations.creating {
    isCanBeConsumed = false
    isTransitive = false
}

dependencies {
    api(project(":tidepool-model"))
    implementation(libs.commons.csv)
    sources(variantOf(libs.commons.csv) { classifier("sources") })
}

tasks.register("showSources") {
    val jars = sources.incoming.files
    doLast { jars.forEach { println(it.name) } }
}
Groovy DSL: tidepool-engine/build.gradle
plugins {
    id 'java-library'
}

configurations {
    sources {
        canBeConsumed = false
        transitive = false
    }
}

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

tasks.register('showSources') {
    def jars = configurations.sources.incoming.files
    doLast { jars.each { println it.name } }
}
$ ./gradlew -q :tidepool-engine:showSources
commons-csv-1.14.1-sources.jar

Both DSLs resolved the sources JAR from the same catalog entry that the production dependency uses, and the task is configuration cache compatible (a second run said Configuration cache entry reused). The same variantOf is used for platform-specific native artifacts (classifier("linux-x86_64")); for test fixtures the manual prefers testFixtures(libs.my.lib) over spelling the classifier. An artifact type is set in an artifact {} block of the dependency (artifact { name = "my-lib"; type = "aar" }), the form that Android builds use; it was not run here because it needs an Android library (see Project Structure & Gradle for a catalog in an Android project). Exclusions and capabilities are written in the dependency’s own configuration closure, as on Dependency Resolution, Conflicts & Capabilities.

Fixing catalog problems

Every problem in a catalog fails the build at settings time, before any project is configured, with a message in the same four-part shape: a title, what is wrong, a link, and a "Possible solution". The link in the message of Gradle 9.8.1 points to version_catalog_problems.html, a page that exists but shows no content; the explanations are on the page How to Fix Version Catalog Problems under the same anchor names (accessor_name_clash, too_many_import_invocation, and so on). The table lists all nineteen documented problems with the cause and the fix; the ones marked "run" were reproduced for this page, and the messages shown on the page above are real output.

Problem (anchor) Cause Fix Run

accessor_name_clash

Two aliases normalise to the same accessor (someAlias and some-alias).

Use distinct aliases.

run

too_many_entries

The catalog exceeds the 32,000 entries that the generated JVM class format allows.

Split the catalog.

no

reserved_alias_name

An alias is extensions, convention, or starts with bundles, plugins or versions.

Rename it (versionsDependency or dependency-versions are valid).

run

undefined_version_reference

version.ref names a version that is not in [versions].

Declare it, or use an existing one. The message lists the existing versions: Use one of the following existing versions: 'assertj', 'commons.csv', 'junit', or 'picocli'.

run

undefined_alias_reference

A bundle contains a library alias that does not exist.

Declare the library or remove it from the bundle (... declares a dependency on 'assertj.coree' which doesn't exist).

run

invalid_dependency_notation

A programmatic library(alias, notation) has a malformed string.

Use group:name:version, or withoutVersion().

no

unsupported_file_format

The imported file is not TOML (for example .txt).

Use a TOML file, or the Settings API.

no

catalog_file_does_not_exist

from(files(...)) points to a file that is not there.

Create the file or fix the path.

run

invalid_alias_notation

An alias does not match the regular expression above (Upper, 1abc).

Rename it.

run

invalid_module_notation

module = "info.picocli" has one part instead of group:name.

Write group:name.

run

toml_syntax_error

The file is not valid TOML (picocli = 4.7.7 without quotes, Alias!, my.lib).

Fix the syntax; the message has the file and the line.

run

unsupported_format_version

The catalog file uses a format version that the installed Gradle does not support.

Upgrade Gradle.

no

invalid_plugin_notation

A plugin entry does not separate the id from the version.

Use the id and version keys in the file, or plugin.id:version in the API.

no

alias_not_finished

A programmatic alias was created but not completed.

End it with version(...), versionRef(...) or withoutVersion().

no

too_many_import_invocation

from called more than once for one catalog.

One from per catalog.

run

no_import_files

The import statement references a file that does not exist.

Make the file available before importing it.

no

too_many_import_files

from was given several files.

Give it exactly one file.

run

invalid_toml_definition

A table has a key that is not allowed there (scope = "test" in a library).

Use only group, module, name and version.

run

invalid_version_notation

A version string cannot be parsed (a strict !! modifier without a base version).

Put a version in front of !!, or drop it.

no

Three of the failures with their exact text, for recognition:

> Undefined version reference
    In version catalog libs, version reference 'picocl' doesn't exist
      Dependency 'info.picocli:picocli' references version 'picocl' which doesn't exist
      For more information, please refer to https://docs.gradle.org/9.8.1/userguide/version_catalog_problems.html#undefined_version_reference in the Gradle documentation.
      Possible solutions:
        1. Declare 'picocl' in the catalog.
        2. Use one of the following existing versions: 'assertj', 'commons.csv', 'junit', or 'picocli'.

> Bundle declares dependency on non-existent alias
    In version catalog libs, a bundle with name 'testing' declares a dependency on 'assertj.coree' which doesn't exist
      Bundles can only contain references to existing library aliases.
      ...
      Possible solutions:
        1. Make sure that the library alias 'assertj.coree' is declared.
        2. Remove 'assertj.coree' from bundle 'testing'.

> Invalid TOML definition
    On library declaration 'extra' expected to find any of 'group', 'module', 'name', or 'version' but found unexpected key 'scope'
      TOML file contains an unexpected key in a known table
      ...
      Possible solution: Remove the unexpected key, or use one of 'group', 'module', 'name', or 'version'.

and the two that depend on TOML syntax:

> TOML syntax error
    Unexpected '.', expected a newline or end-of-input
      TOML syntax invalid
      ...
      Location: /work/tidepool/gradle/libs.versions.toml:5
      Possible solution: Fix the TOML file according to the syntax described at https://toml.io.

> Invalid module notation
    In version catalog libs, on alias 'picocli' module 'info.picocli' is not a valid module notation
      When using a string to declare library module coordinates, you must use a valid module notation
      ...
      Possible solution: Make sure that the module consist of 2 parts separated by colons, eg: my.group:artifact.

Source: Gradle User Manual: How to Fix Version Catalog Problems (with the anchors too_many_entries, reserved_alias_name, undefined_version_reference, undefined_alias_reference, invalid_dependency_notation, unsupported_file_format, catalog_file_does_not_exist, invalid_alias_notation, invalid_module_notation, toml_syntax_error, unsupported_format_version, invalid_plugin_notation, alias_not_finished, too_many_import_invocation, no_import_files, too_many_import_files, invalid_toml_definition and invalid_version_notation)

Naming entries

The best practices page asks for consistent, descriptive aliases and gives rules that make the accessors read well. They are guidelines, not checked by Gradle:

  • Separate segments with dashes, not underscores (log4j-api).

  • Derive the first segment from the group, without the top-level domain (jackson-databind, armeria-grpc), and the second from the artifact.

  • Leave out generic terms such as core, java, gradle, module or sdk when they stand alone (google-java-format, not google-java).

  • Omit a repeated segment when group and artifact agree (ktor-client-core, not ktor-ktor-client-core).

  • Turn internal dashes of the artifact into camelCase (springBootStarterWeb), which also avoids unwanted sub-groups.

  • Suffix a plugin that is declared as a library (not in [plugins]) with -plugin (dependency-check-plugin).

The same page warns against the opposite misuse: a catalog is for coordinates and versions, not for shared strings, constants or build logic. It also says that a catalog only influences declared versions, so combine it with dependency locking and version alignment when the resolved versions must be pinned.

Starting from gradle init

Since Gradle 8.5 the Build Init plugin generates a catalog. Run on 9.8.1 with gradle init --type java-application --dsl kotlin --test-framework junit-jupiter --package demo --project-name demo --java-version 21 --use-defaults, it wrote this file (with a comment header pointing to the TOML documentation), plus a build script that already uses libs.junit.jupiter and libs.guava:

# This file was generated by the Gradle 'init' task.
# https://docs.gradle.org/current/userguide/version_catalogs.html#sec::toml-dependencies-format

[versions]
guava = "33.5.0-jre"
junit-jupiter = "6.0.1"

[libraries]
guava = { module = "com.google.guava:guava", version.ref = "guava" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" }

Treat the generated versions as a starting point to bump (the Versions plugin above is the tool for that). The generated build leaves testRuntimeOnly("org.junit.platform:junit-platform-launcher") as a string with no version, and it still resolves: running dependencies on the test runtime classpath of the generated project showed junit-platform-launcher -> 6.0.1, because junit-jupiter brings the JUnit BOM into the graph. See Installation for gradle init itself.

Gradle 8.14 and Gradle 9.8.1

Gradle 8.14.x was not available here, so this table is from the official documentation and is not run here:

Topic Difference between 8.14 and 9.8.1

Catalog file, accessors, settings API, version-catalog plugin

Version catalogs have been stable since 7.4, and the manual describes the same format and API. The changes in between are the ones in the history table above: gradle init generating a catalog (8.5), the TOML parser (8.7) and optional plugin versions (8.8).

Libraries and bundles in the plugins {} block (Kotlin DSL)

On 8.14 this is a deprecation warning (deprecated in 8.1); on 9.x it is the build failure shown above.

JVM to run Gradle

8.14 runs on JVM 8 and newer; 9.x needs JVM 17 or newer to run the build (see Versions, Compatibility & Release History).

Configuration cache

Off by default in both; preferred mode in 9.x. Every example above is compatible with it.

Equivalents in Maven and Ant with Ivy

Concern Gradle Maven Ant with Ivy

One place for versions

[versions] in gradle/libs.versions.toml

<properties> in the (parent) POM, referenced with the property syntax in dependency versions

A properties file loaded with <property file="..."/>, which Ivy sees as variables in ivy.xml and ivysettings.xml

One place for coordinates

[libraries] with typed accessors

<dependencyManagement> in a parent POM or an imported BOM; the dependency is still written with its groupId and artifactId in each module, without the version

ivy.xml per module; no shared catalog

Group that travels together

[bundles]

A POM with <type>pom</type> and <scope>import</scope>, or a dependency on an aggregating artifact

An Ivy configuration or a dependency on a module that depends on the others

Share across builds

version-catalog plugin, published as .toml

Publish a parent POM or a BOM and inherit or import it

Publish the properties file or the settings file, which can be loaded from a URL

Decides the resolved version?

No (use a platform)

Yes: managed versions apply to transitive dependencies too

Through Ivy conflict managers and overrides

Classic mistakes

Mistake What happens and the fix

Treating the catalog version as the resolved version

A platform, a constraint, a higher transitive requirement or a rule changes the selection. Check with dependencies or dependencyInsight.

create("libs") { from(files("gradle/libs.versions.toml")) } in settings

The file is already imported, so from is called twice: Multiple 'from' invocations. Delete the block, or rename the file.

Using libs in buildSrc or build-logic without importing the file

Unresolved reference 'libs' (Kotlin DSL) or Could not get unknown property 'libs' (Groovy DSL). Import the file in the settings of that build.

A library or bundle in the plugins {} block

Fails on Gradle 9 (Accessing libraries or bundles from version catalogs in the plugins block is not allowed). Use plugin aliases only.

A catalog plugin alias in the settings file

Not supported: the catalog does not exist yet while the settings are evaluated.

Aliases that differ only in separators or case

Accessor name clash. One spelling per name.

libs.versions.jackson.get() when jackson-databind also exists

The sub-group has no get(); use asProvider(), or camelCase names.

A version-less alias without a platform

Could not find org.junit.jupiter:junit-jupiter:. (empty version). Import the BOM, or give the alias a version.

Putting a classifier, an exclusion or a capability in the TOML file

Not supported (Invalid TOML definition for an unknown key). Declare it in the build script around the catalog entry.

Reading the catalog extension inside doLast

Captures the project and breaks the configuration cache; read it while configuring and keep only values.

Editing the published TOML or trusting a floating catalog version

Pin an exact catalog version and verify it like any other artifact.

Where to go next

The projects that use a catalog and the shared logic around it are on Multi-Project Builds and Convention Plugins & Sharing Build Logic; the way a version is finally chosen is on Dependency Resolution, Conflicts & Capabilities and Resolution Rules & Substitution; the migration of a Maven build, whose versions go into a catalog, is on Migrating from Maven; and the recommended habits are collected on Best Practices & Troubleshooting.