You can specify dependencies with exact versions or version ranges to define which versions your project can use:

dependencies {
    implementation("org.springframework:spring-core:5.3.8")
    implementation("org.springframework:spring-core:5.3.+")
    implementation("org.springframework:spring-core:latest.release")
    implementation("org.springframework:spring-core:[5.2.0, 5.3.8]")
    implementation("org.springframework:spring-core:[5.2.0,)")
}

Understanding version declaration

Gradle supports various ways to declare versions and ranges:

Version Example Note

Exact version

1.3, 1.3.0-beta3, 1.0-20150201.131010-1

A specific version.

Maven-style range

[1.0,), [1.1, 2.0), (1.2, 1.5]

[ ] indicates inclusive bounds; ( ) indicates exclusive bounds. See below to learn more.

When the upper or lower bound is missing, the range has no upper or lower bound.

An upper bound exclude acts as a prefix exclude.

Prefix version range

1.+, 1.3.+

Only versions exactly matching the portion before the + are included.

Declaring a version as +, without any prefix, will include any version.

latest-status version

latest.integration, latest.release

Matches the highest version with the specified status. See ComponentMetadata.getStatus().

Maven SNAPSHOT version

1.0-SNAPSHOT, 1.4.9-beta1-SNAPSHOT

Indicates a snapshot version.

Maven-style range

There are a number of options to indicate bounds in the Maven-style:

  • [ and ] indicate an inclusive bound → [1.1, 2.0]

  • ( and ) indicate an exclusive bound → (1.1, 2.0) or (1.2, 1.5] or [1.1, 2.0)

  • ] can be used instead of ( for an exclusive lower bound → ]1.2, 1.5] instead of (1.2, 1.5]

  • [ can be used instead of ) for exclusive upper bound → [1.1, 2.0[ instead of [1.1, 2.0)

Understanding version ordering

dependencies {
    implementation("org.springframework:spring-core:1.1") // This is a newer version than 1.a
    implementation("org.springframework:spring-core:1.a") // This is a older version than 1.1
}

Version ordering is used to:

  • Determine if a particular version is included in a range.

  • Determine which version is newest when performing conflict resolution (using "base versions").

Versions are ordered based on the following rules:

  • Splitting Versions into Parts:

    • Versions are divided into parts using the characters [. - _ +].

    • Parts containing both digits and letters are split further, e.g., 1a1 becomes 1.a.1.

    • Only the parts are compared, not the separators, so 1.a.1, 1-a+1, 1.a-1, and 1a1 are equivalent. (Note: There are exceptions during conflict resolution).

  • Comparing Equivalent Parts:

    • Numeric vs. Numeric: Higher numeric value is considered higher: 1.1 < 1.2.

    • Numeric vs. Non-numeric: Numeric parts are higher than non-numeric parts: 1.a < 1.1.

    • Non-numeric vs. Non-numeric: Parts are compared alphabetically and case-sensitively: 1.A < 1.B < 1.a < 1.b.

    • Extra Numeric Part: A version with an additional numeric part is higher, even if it’s zero: 1.1 < 1.1.0.

    • Extra Non-numeric Part: A version with an extra non-numeric part is lower: 1.1.a < 1.1.

  • Special Non-numeric Parts:

    • dev is lower than any other non-numeric part: 1.0-dev < 1.0-ALPHA < 1.0-alpha < 1.0-rc.

    • rc, snapshot, final, ga, release, and sp are higher than any other string part, in this order: 1.0-zeta < 1.0-rc < 1.0-snapshot < 1.0-final < 1.0-ga < 1.0-release < 1.0-sp.

    • These special values are not case-sensitive and their ordering does not depend on the separator used: 1.0-RC-1 == 1.0.rc.1.

Declaring rich versions

When you declare a version using the shorthand notation, then the version is considered a required version:

build.gradle.kts
dependencies {
    implementation("org.slf4j:slf4j-api:1.7.15")
}
build.gradle
dependencies {
    implementation('org.slf4j:slf4j-api:1.7.15')
}

This means the minimum version will be 1.7.15 and it can be optimistically upgraded by the engine.

To enforce a strict version and ensure that only the specified version of a dependency is used, rejecting any other versions even if they would normally be compatible:

build.gradle.kts
dependencies {
    implementation("org.slf4j:slf4j-api") {
        version {
            strictly("[1.7, 1.8[")
            prefer("1.7.25")
        }
    }
}
build.gradle
dependencies {
    implementation('org.slf4j:slf4j-api') {
        version {
            strictly '[1.7, 1.8['
            prefer '1.7.25'
        }
    }
}

Gradle supports a model for rich version declarations, allowing you to combine different levels of version specificity.

The key terms, listed from strongest to weakest, are:

strictly or !!

This is the strongest version declaration. Any version not matching this notation will be excluded. If used on a declared dependency, strictly can downgrade a version. For transitive dependencies, if no acceptable version is found, dependency resolution will fail.

Dynamic versions are supported.

When defined, it overrides any previous require declaration and clears any previous reject already declared on that dependency.

When more than one strictly declaration applies to the same dependency, the resolution rules depend on whether the declarations are at the same level or different graph depths — see Competing strictly declarations for the full behavior.

strictly is meant to replace force. The older resolutionStrategy.force() mechanism is still available (see Forcing a Version at the Configuration Level) but new builds should prefer strictly, which integrates with rich versions, propagates through Gradle Module Metadata, and participates in conflict resolution.
require

This ensures that the selected version cannot be lower than what require accepts, but it can be higher through conflict resolution, even if the higher version has an exclusive upper bound. This is the default behavior for a direct dependency.

Dynamic versions are supported.

When defined, it overrides any previous strictly declaration and clears any previous reject already declared on that dependency.

prefer

This is the softest version declaration. It applies only if there is no stronger non-dynamic version specified.

This term does not support dynamic versions and can complement strictly or require.

When defined, it overrides any previous prefer declaration and clears any previous reject already declared on that dependency.

Additionally, there is a term outside the hierarchy:

reject

This term specifies versions that are not accepted for the module, causing dependency resolution to fail if a rejected version is selected.

Dynamic versions are supported.

Rich version declaration is accessed through the version DSL method on a dependency or constraint declaration, which gives you access to MutableVersionConstraint:

build.gradle.kts
dependencies {
    implementation("org.slf4j:slf4j-api") {
        version {
            strictly("[1.7, 1.8[")
            prefer("1.7.25")
        }
    }

    constraints {
        add("implementation", "org.springframework:spring-core") {
            version {
                require("4.2.9.RELEASE")
                reject("4.3.16.RELEASE")
            }
        }
    }
}
build.gradle
dependencies {
    implementation('org.slf4j:slf4j-api') {
        version {
            strictly '[1.7, 1.8['
            prefer '1.7.25'
        }
    }

    constraints {
        implementation('org.springframework:spring-core') {
            version {
                require '4.2.9.RELEASE'
                reject '4.3.16.RELEASE'
            }
        }
    }
}

To enforce strict versions, you can also use the !! notation:

build.gradle.kts
dependencies {
    // short-hand notation with !!
    implementation("org.slf4j:slf4j-api:1.7.15!!")
    // is equivalent to
    implementation("org.slf4j:slf4j-api") {
        version {
           strictly("1.7.15")
        }
    }

    // or...
    implementation("org.slf4j:slf4j-api:[1.7, 1.8[!!1.7.25")
    // is equivalent to
    implementation("org.slf4j:slf4j-api") {
        version {
           strictly("[1.7, 1.8[")
           prefer("1.7.25")
        }
    }
}
build.gradle
dependencies {
    // short-hand notation with !!
    implementation('org.slf4j:slf4j-api:1.7.15!!')
    // is equivalent to
    implementation("org.slf4j:slf4j-api") {
        version {
           strictly '1.7.15'
        }
    }

    // or...
    implementation('org.slf4j:slf4j-api:[1.7, 1.8[!!1.7.25')
    // is equivalent to
    implementation('org.slf4j:slf4j-api') {
        version {
           strictly '[1.7, 1.8['
           prefer '1.7.25'
        }
    }
}

The notation [1.7, 1.8[!!1.7.25 above is equivalent to:

  • strictly [1.7, 1.8[

  • prefer 1.7.25

This means that the engine must select a version between 1.7 (included) and 1.8 (excluded). If no other component in the graph needs a different version, it should prefer 1.7.25.

A strict version cannot be upgraded and overrides any transitive dependency versions, therefore using ranges with strict versions is recommended.

The following table illustrates several use cases:

Which version(s) of this dependency are acceptable? strictly require prefer rejects Selection result

Tested with version 1.5; believe all future versions should work.

1.5

Any version starting from 1.5, equivalent to org:foo:1.5. An upgrade to 2.4 is accepted.

Tested with 1.5, soft constraint upgrades according to semantic versioning.

[1.0, 2.0[

1.5

Any version between 1.0 and 2.0, 1.5 if nobody else cares. An upgrade to 2.4 is accepted.
🔒

Tested with 1.5, but follows semantic versioning.

[1.0, 2.0[

1.5

Any version between 1.0 and 2.0 (exclusive), 1.5 if nobody else cares.
Overwrites versions from transitive dependencies.
🔒

Same as above, with 1.4 known broken.

[1.0, 2.0[

1.5

1.4

Any version between 1.0 and 2.0 (exclusive) except for 1.4, 1.5 if nobody else cares.
Overwrites versions from transitive dependencies.
🔒

No opinion, works with 1.5.

1.5

1.5 if no other opinion, any otherwise.

No opinion, prefer the latest release.

latest.release

The latest release at build time.
🔒

On the edge, latest release, no downgrade.

latest.release

The latest release at build time.
🔒

No other version than 1.5.

1.5

1.5, or failure if another strict or higher require constraint disagrees.
Overwrites versions from transitive dependencies.

1.5 or a patch version of it exclusively.

[1.5,1.6[

Latest 1.5.x patch release, or failure if another strict or higher require constraint disagrees.
Overwrites versions from transitive dependencies.
🔒

Lines annotated with a lock (🔒) indicate situations where leveraging dependency locking is recommended. NOTE: When using dependency locking, publishing resolved versions is always recommended.

Using strictly in a library requires careful consideration, as it affects downstream consumers. However, when used correctly, it helps consumers understand which combinations of libraries may be incompatible in their context. For more details, refer to the section on overriding dependency versions.

Rich version information is preserved in the Gradle Module Metadata format. However, converting this information to Ivy or Maven metadata formats is lossy. The highest level of version declaration—strictly or require over prefer—will be published, and any reject will be ignored.

Rich versions — including strictly, require, prefer, and reject — can also be declared in a version catalog using the rich-version TOML object, for example: guava = { strictly = "[33.0, 34.0[", prefer = "33.0.0-jre" }.

Choosing between strictly, reject, and require

Use the table below to pick the right declaration for a given scenario; each row corresponds to a worked example in the subsections that follow.

Goal Declaration Why

Prevent a transitive upgrade past a known-good version

strictly("[1.9, 1.10[")

Strict ranges cannot be overridden by transitive (deeper) participants in the graph.

Exclude a single known-broken version

require("[1.0, 2.0[") + reject("1.4")

reject removes specific versions from the candidate set without constraining the overall range.

Bound versions for library consumers while staying flexible

strictly("[1.0, 2.0[") + prefer("1.7.25")

The strict range caps consumers; prefer picks a default when no one else has an opinion.

Enforce a minimum version for a security fix

constraints { strictly("[2.17.1, 2.18[") }

Applied as a constraint so it only activates when the module appears in the graph.

Exclude multiple known-broken versions

reject("1.4.0", "1.5.0")

reject is varargs and removes each listed version from the candidate set.

Make a dependency constraint genuinely cap a version

Use strictly inside constraints { }, not a bare version

A bare version in constraints { } is treated as require, which permits upgrades.

Use strictly when you need to prevent version upgrades

A strictly declaration pins a dependency to a version (or version range) that transitive dependencies cannot override. This is useful when you know that versions outside a specific range are incompatible with your project:

build.gradle.kts
dependencies {
    // Your project depends on an API from Commons Codec 1.9 that was removed in 1.10.
    // Use strictly to prevent HttpClient from upgrading it to 1.10 transitively.
    implementation("commons-codec:commons-codec") {
        version {
            strictly("[1.9, 1.10[")
        }
    }
    implementation("org.apache.httpcomponents:httpclient:4.5.4") // brings in commons-codec:1.10
}
build.gradle
dependencies {
    // Your project depends on an API from Commons Codec 1.9 that was removed in 1.10.
    // Use strictly to prevent HttpClient from upgrading it to 1.10 transitively.
    implementation('commons-codec:commons-codec') {
        version {
            strictly '[1.9, 1.10['
        }
    }
    implementation('org.apache.httpcomponents:httpclient:4.5.4') // brings in commons-codec:1.10
}

Without strictly, Gradle’s default conflict resolution would select 1.10 (the highest version), because a standard require declaration allows upgrades.

This downgrade works because the transitive requirement on commons-codec is a plain require, which expresses a preference rather than a hard floor and does not reject lower versions. The direct strictly '[1.9, 1.10[' actively rejects 1.10, and because the require yields to it, resolution settles on 1.9. Even if a transitive dependency declared its own strictly for a higher version, the direct strictly would take precedence. A direct strictly declaration wins over a transitive one.

Use reject when specific versions are known to be broken

A reject declaration excludes individual versions from consideration without constraining the overall range. Use this when most versions are acceptable but one or more have known defects:

build.gradle.kts
dependencies {
    // Version 1.4 has a known bug; any other version in the 1.x range is fine.
    implementation("commons-io:commons-io") {
        version {
            require("[1.0, 2.0[")
            reject("1.4")
        }
    }
}
build.gradle
dependencies {
    // Version 1.4 has a known bug; any other version in the 1.x range is fine.
    implementation('commons-io:commons-io') {
        version {
            require '[1.0, 2.0['
            reject '1.4'
        }
    }
}

If the only version available is 1.4, resolution will fail. Unlike strictly, reject does not prevent upgrades, it only removes specific versions from the candidate set.

Combine strictly with prefer for library-friendly constraints

When publishing a library, a pinned strictly (e.g., strictly("1.9")) can cause resolution failures for consumers who need a different version. Use a strict range with prefer to set boundaries while remaining flexible:

build.gradle.kts
dependencies {
    implementation("org.slf4j:slf4j-api") {
        version {
            strictly("[1.0, 2.0[")  // consumers must stay within 1.x
            prefer("1.7.25")        // use 1.7.25 when no other opinion exists
        }
    }
}
build.gradle
dependencies {
    implementation('org.slf4j:slf4j-api') {
        version {
            strictly '[1.0, 2.0['   // consumers must stay within 1.x
            prefer '1.7.25'         // use 1.7.25 when no other opinion exists
        }
    }
}

A consumer of your library that requires 1.8.0 will resolve successfully because the version falls within the strict range. A consumer that requires 2.0.0 will get a resolution error, because 2.0.0 is outside the strict range.

Use strictly to enforce a minimum version for security fixes

When a vulnerability is discovered in a transitive dependency, use strictly with a range to ensure no dependency in the graph can pull in the affected version:

build.gradle.kts
dependencies {
    constraints {
        implementation("org.apache.logging.log4j:log4j-core") {
            version {
                strictly("[2.17.1, 2.18[")
            }
            because("CVE-2021-44832: versions prior to 2.17.1 are vulnerable")
        }
    }
}
build.gradle
dependencies {
    constraints {
        implementation('org.apache.logging.log4j:log4j-core') {
            version {
                strictly '[2.17.1, 2.18['
            }
            because 'CVE-2021-44832: versions prior to 2.17.1 are vulnerable'
        }
    }
}

Here, strictly is used to upgrade rather than downgrade; any transitive dependency that brings in a version below 2.17.1 will be overridden.

The strict version is declared inside a constraints { } block rather than as a direct dependency for an important reason: a constraint only takes effect when something else in the build already pulls in log4j-core. If your code does not use log4j directly, you should not add it as a direct dependency just to express a security policy, doing so would artificially add log4j to projects that have no reason to depend on it (and would still appear on the classpath even if every transitive dependency stopped using it). A constraint expresses "if log4j-core is in the graph, it must be at least 2.17.1," which is exactly the right semantic for a security floor.

Use reject to exclude multiple known-bad versions

You can call reject multiple times to exclude several specific versions:

build.gradle.kts
dependencies {
    implementation("com.google.guava:guava") {
        version {
            require("[32.0, 33.0[")
            reject("32.1.1-jre", "32.1.2-jre")
        }
    }
}
build.gradle
dependencies {
    implementation('com.google.guava:guava') {
        version {
            require '[32.0, 33.0['
            reject '32.1.1-jre', '32.1.2-jre'
        }
    }
}

This resolves to the highest available version in the [32.0, 33.0[ range that is neither 32.1.1-jre nor 32.1.2-jre.

Use strictly in dependency constraints to actually constrain

When you declare a version in a dependency constraint, the version is treated as require, not strictly. This means a transitive dependency can still upgrade past the version you specified:

build.gradle.kts
dependencies {
    // Brings in commons-codec:1.10
    implementation("org.apache.httpcomponents:httpclient:4.5.4")

    constraints {
        // This does NOT prevent commons-codec from resolving to 1.10.
        // The version "1.9" is treated as "require 1.9", which allows upgrades.
        implementation("commons-codec:commons-codec:1.9")
    }
}

// Result: commons-codec resolves to 1.10 — the constraint is satisfied because 1.10 >= 1.9
build.gradle
dependencies {
    // Brings in commons-codec:1.10
    implementation('org.apache.httpcomponents:httpclient:4.5.4')

    constraints {
        // This does NOT prevent commons-codec from resolving to 1.10.
        // The version "1.9" is treated as "require 1.9", which allows upgrades.
        implementation('commons-codec:commons-codec:1.9')
    }
}

// Result: commons-codec resolves to 1.10 — the constraint is satisfied because 1.10 >= 1.9

To actually cap the version, use strictly inside the constraint:

build.gradle.kts
dependencies {
    implementation("org.apache.httpcomponents:httpclient:4.5.4")

    constraints {
        implementation("commons-codec:commons-codec") {
            version {
                strictly("[1.0, 1.10[")
                prefer("1.9")
            }
            because("API we depend on was removed in 1.10")
        }
    }
}
build.gradle
dependencies {
    implementation('org.apache.httpcomponents:httpclient:4.5.4')

    constraints {
        implementation('commons-codec:commons-codec') {
            version {
                strictly '[1.0, 1.10['
                prefer '1.9'
            }
            because 'API we depend on was removed in 1.10'
        }
    }
}

This distinction matters because dependency constraints only take effect when the module appears in the graph. If no dependency brings in commons-codec, the constraint is ignored entirely. Adding strictly ensures that when the module appears, its version is genuinely constrained.

Endorsing strict versions

Gradle resolves any dependency version conflicts by selecting the greatest version found in the dependency graph. Some projects might need to divert from the default behavior and enforce an earlier version of a dependency e.g. if the source code of the project depends on an older API of a dependency than some of the external libraries.

In general, forcing dependencies is done to downgrade a dependency. There are common use cases for downgrading:

  • A bug was discovered in the latest release.

  • Your code depends on an older version that is not binary compatible with the newer one.

  • Your code does not use the parts of the library that require a newer version.

Forcing a version of a dependency requires careful consideration, as changing the version of a transitive dependency might lead to runtime errors if external libraries expect a different version. It is often better to upgrade your source code to be compatible with newer versions if possible.

Let’s say a project uses the HttpClient library for performing HTTP calls. HttpClient pulls in Commons Codec as transitive dependency with version 1.10. However, the production source code of the project requires an API from Commons Codec 1.9 which is no longer available in 1.10. The dependency version can be enforced by declaring it as strict it in the build script:

build.gradle.kts
dependencies {
    implementation("org.apache.httpcomponents:httpclient:4.5.4")
    implementation("commons-codec:commons-codec") {
        version {
            strictly("1.9")
        }
    }
}
build.gradle
dependencies {
    implementation 'org.apache.httpcomponents:httpclient:4.5.4'
    implementation('commons-codec:commons-codec') {
        version {
            strictly '1.9'
        }
    }
}

Consequences of using strict versions

Using a strict version must be carefully considered:

  • For Library Authors: Strict versions effectively act like forced versions. They take precedence over transitive dependencies and override any other strict versions found transitively. This could lead to build failures if the consumer project requires a different version.

  • For Consumers: Strict versions are considered globally during resolution. If a strict version conflicts with a consumer’s version requirement, it will trigger a resolution error.

For example, if project B strictly depends on C:1.0, but consumer project A requires C:1.1, a resolution error will occur.

To avoid this, it is recommended to use version ranges and a preferred version within those ranges.

For example, B might say, instead of strictly 1.0, that it strictly depends on the [1.0, 2.0[ range, but prefers 1.0. Then if a consumer chooses 1.1 (or any other version in the range), the build will no longer fail.

Declaring without version

For larger projects, it’s advisable to declare dependencies without versions and manage versions using platforms:

build.gradle.kts
dependencies {
    implementation("org.springframework:spring-web")
}

dependencies {
    constraints {
        implementation("org.springframework:spring-web:5.0.2.RELEASE")
    }
}
build.gradle
dependencies {
    implementation 'org.springframework:spring-web'
}

dependencies {
    constraints {
        implementation 'org.springframework:spring-web:5.0.2.RELEASE'
    }
}

This approach centralizes version management, including transitive dependencies.

Declaring dynamic versions

There are many situations where you might need to use the latest version of a specific module dependency or the latest within a range of versions. This is often necessary during development or when creating a library that needs to be compatible with various dependency versions. Projects might adopt a more aggressive approach to consuming dependencies by always integrating the latest version to access cutting-edge features.

You can easily manage these ever-changing dependencies by using a dynamic version. A dynamic version can be either a version range (e.g., 2.+) or a placeholder for the latest available version (e.g., latest.integration):

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

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.springframework:spring-web:5.+")
}
build.gradle
plugins {
    id 'java-library'
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework:spring-web:5.+'
}

Using dynamic versions and changing modules can lead to unreproducible builds. As new versions of a module are published, its API may become incompatible with your source code. Therefore, use this feature with caution.

For reproducible builds, it’s crucial to use dependency locking when declaring dependencies with dynamic versions. Without this, the module you request may change even for the same version, which is known as a changing version. For example, a Maven SNAPSHOT module always points to the latest artifact published, making it a "changing module."

Declaring changing versions

A team may implement a series of features before releasing a new version of the application or library. A common strategy to allow consumers to integrate an unfinished version of their artifacts early is to release a module with a changing version. A changing version indicates that the feature set is still under active development and hasn’t released a stable version for general availability yet.

In Maven repositories, changing versions are commonly referred to as snapshot versions. Snapshot versions contain the suffix -SNAPSHOT.

The following example demonstrates how to declare a snapshot version on the Spring dependency:

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

repositories {
    mavenCentral()
    maven {
        url = uri("https://repo.spring.io/snapshot/")
    }
}

dependencies {
    implementation("org.springframework:spring-web:5.0.3.BUILD-SNAPSHOT")
}
build.gradle
plugins {
    id 'java-library'
}

repositories {
    mavenCentral()
    maven {
        url = 'https://repo.spring.io/snapshot/'
    }
}

dependencies {
    implementation 'org.springframework:spring-web:5.0.3.BUILD-SNAPSHOT'
}

Gradle is flexible enough to treat any version as a changing version. All you need to do is to set the property ExternalModuleDependency.setChanging(boolean) to true.

Versioning file dependencies

It is recommended to clearly express the intention and specify a concrete version when using file dependencies.

File dependencies are not considered by Gradle’s version conflict resolution. Therefore, assigning a version to the file name is crucial to indicate the distinct set of changes included in each release.

For example, using commons-beanutils-1.3.jar allows tracking changes in the library through its release notes.

By following this practice:

  • Project dependencies become easier to maintain and organize.

  • Potential API incompatibilities are easier to identify through the assigned version.