Lazy Configuration & Providers
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Gradle 9.8.1 (the current release on 2026-10-16; 8.14.6 is the last 8.x release). Every example was run through the Gradle wrapper on
JDK 26, on macOS, in small scratch builds that read the tidepool data/stations.csv, once with the Kotlin DSL and once with the Groovy DSL, and both were run. The
daemon used JDK 26; Java toolchains are the subject of a later page. Each example was run with --configuration-cache, and the text says whether it is compatible;
the examples that fail on purpose say so. Gradle 8.14 was not available here, so what is said about it comes from the release notes and the user manual and is
labelled "not run here". The build output was trimmed only where a line says so, blank lines and the problems-report link were left out, and the scratch directory was
replaced by <work>. Gradle does not promise an order for independent tasks, so the order of such lines in the output is not significant.
A Gradle build runs in two phases that matter here: configuration, when the build scripts run and the task graph is built, and execution, when the selected tasks run
(see Build Lifecycle). Anything a script computes while it is being configured is computed for
every build, including gradle help, and it is computed before later lines of the same script and before other plugins have had their say. Lazy configuration is
Gradle’s answer: instead of holding a value, the build model holds an object that knows how to compute the value later. Those objects are Provider<T> and
Property<T>, and much of the public Gradle API takes or returns them. This page covers the types, how to chain and wire them, the places they
come from, and the mistakes that defeat them. Tasks that declare lazy inputs are written on
Writing Custom Tasks; how those inputs make a task skip work is on
Incremental Build; creating and ordering tasks lazily is on
Tasks; the vocabulary is on
Gradle Core Concepts.
Why a value should not be read at configuration time
Here is the problem the API exists to solve. A build script sets a task input from an extension property, and the extension property is assigned later, by the
same script or by another plugin. The task below announces a station. Two tasks are registered: one reads the extension property now, with get(), and one passes the
property itself:
interface StationExtension {
val stationId: Property<String>
}
abstract class Announce : DefaultTask() {
@get:Input
abstract val station: Property<String>
@TaskAction
fun announce() {
println("${path}: station = ${station.get()}")
}
}
val tidepool = extensions.create<StationExtension>("tidepool")
tidepool.stationId.convention("SF")
tasks.create<Announce>("announceEager") {
station = tidepool.stationId.get() // create runs this block now: the value is read now
}
tasks.register<Announce>("announceLazy") {
station = tidepool.stationId // evaluated when the task runs
}
// Later in the same script, the build author picks the real station.
tidepool.stationId = "BOS"
interface StationExtension {
Property<String> getStationId()
}
abstract class Announce extends DefaultTask {
@Input
abstract Property<String> getStation()
@TaskAction
void announce() {
println "${path}: station = ${station.get()}"
}
}
def tidepool = extensions.create('tidepool', StationExtension)
tidepool.stationId.convention('SF')
tasks.create('announceEager', Announce) {
station = tidepool.stationId.get() // create runs this block now: the value is read now
}
tasks.register('announceLazy', Announce) {
station = tidepool.stationId // evaluated when the task runs
}
// Later in the same script, the build author picks the real station.
tidepool.stationId = 'BOS'
./gradlew announceLazy announceEager --configuration-cache
Calculating task graph as no cached configuration is available for tasks: announceLazy announceEager
> Task :announceLazy
:announceLazy: station = BOS
> Task :announceEager
:announceEager: station = SF
BUILD SUCCESSFUL in 677ms
2 actionable tasks: 2 executed
Configuration cache entry stored.
The Groovy DSL build printed the same two station lines and ended with Configuration cache entry stored. The task that was connected to the property saw the
final value, BOS. The task that called get() saw SF, the convention that was current when the script reached that line: tasks.create runs its configuration
block immediately, so the read happened before tidepool.stationId = "BOS", and nothing can correct it afterwards. Nothing failed and nothing warned, which is what
makes this a bug that is found late. Both builds are compatible with the configuration cache and reused the entry on a second run; the incorrectness here is in the
value, not in the cache. The Kotlin DSL also printed a compile warning for tasks.create ('fun <reified T : Task> TaskContainer.create(...)' is deprecated. Use register instead), which is the same lesson from another angle and is taken up in Registering tasks lazily.
The official page lists three benefits of lazy properties: the value of the model can be wired before it is known, an output wired to an input carries the task that produces it (so task dependencies are inferred), and expensive work done to compute a value only happens when something needs it. The rest of the page shows each of them.
Source: Gradle: Lazy vs eager evaluation
Property and Provider
Gradle models lazy values with two interfaces:
-
Provider<T>is a read-only value that can be queried.Provider.get()returns the value, and aProvidercan be derived from another one withmap. -
Property<T>extendsProvider<T>and adds a way to change the value:set(T),set(Provider)and, in the Kotlin DSL and in the Groovy DSL, plain assignment with=. Setting aPropertyto aProvideris what connects one part of the model to another.
Neither interface is implemented by build code. Gradle creates the instances through factories, so a build script or plugin never writes new Property anywhere:
| Need | Type | Factory |
|---|---|---|
A configurable single value |
|
|
A read-only computed value |
|
|
A configurable file or directory |
|
|
A read-only file or directory |
|
|
A configurable list, set or map |
|
|
A set of files |
|
|
A read-only set of files |
|
|
A Provider can also be created from a lambda with providers.provider { }, but the Gradle documentation says to always prefer one of the other factory methods over it.
The Javadoc page for
Provider documents every method named below.
A property has two kinds of states besides its value: "no value yet" and a convention. A missing value is not an error until somebody asks for it:
isPresent() tells, getOrElse(default) and orNull ask without failing, and get() fails with a message that names the source of the value. The next section runs all
of them.
Transforming and combining providers
Provider has a handful of operations, all lazy: each returns a new provider and none evaluates its input until the result is queried.
| Method | Result |
|---|---|
|
A provider of the transformed value. When the source has no value, the result has no value either and the lambda is not called. |
|
The lambda returns another provider and the result is flattened. Use it when the transformation itself yields a |
|
Combines two providers. Added in Gradle 6.6; if either side has no value, the result has none and the combiner is not called. |
|
A fallback used when the source has no value. Added in Gradle 5.6. |
|
The value only if the predicate holds, otherwise no value. Incubating, since Gradle 8.5. |
|
Queries. |
The example reads a station id from a Gradle property, falls back to SF, reads the tidepool CSV lazily with providers.fileContents, finds the row for the
station by zipping the file with the id, and derives the name and a flag from it. The only get() calls are inside the task action:
// A provider for a Gradle property: absent unless -Pstation=... is given
val requested: Provider<String> = providers.gradleProperty("station")
// orElse supplies a fallback
val stationId: Provider<String> = requested.orElse("SF")
// the CSV file is read lazily; fileContents is configuration-cache friendly
val csv = providers.fileContents(layout.projectDirectory.file("data/stations.csv")).asText
// map transforms the value, zip combines two providers
val row: Provider<String> = csv.zip(stationId) { text, id ->
text.lines().first { it.startsWith("$id,") }
}
val stationName: Provider<String> = row.map { it.split(",")[1] }
val upper: Provider<String> = stationName.map { it.uppercase() }
// filter drops the value unless the predicate holds: stations west of 100 degrees west
val western: Provider<String> = row
.filter { it.split(",")[3].toDouble() < -100 }
.map { it.split(",")[1] }
tasks.register("describe") {
val requested = requested
val stationId = stationId
val upper = upper
val western = western
doLast {
println("requested present? " + requested.isPresent)
println("requested orNull = " + requested.orNull)
println("requested getOrElse = " + requested.getOrElse("(none)"))
println("station id = " + stationId.get())
println("station name = " + upper.get())
println("west of 100 W = " + western.isPresent)
}
}
tasks.register("missing") {
val requested = requested
doLast { println(requested.get()) }
}
// A provider for a Gradle property: absent unless -Pstation=... is given
Provider<String> requested = providers.gradleProperty('station')
// orElse supplies a fallback
Provider<String> stationId = requested.orElse('SF')
// the CSV file is read lazily; fileContents is configuration-cache friendly
def csv = providers.fileContents(layout.projectDirectory.file('data/stations.csv')).asText
// map transforms the value, zip combines two providers
Provider<String> row = csv.zip(stationId) { text, id ->
text.readLines().find { it.startsWith("${id},") }
}
Provider<String> stationName = row.map { it.split(',')[1] }
Provider<String> upper = stationName.map { it.toUpperCase() }
// filter drops the value unless the predicate holds: stations west of 100 degrees west
Provider<String> western = row
.filter { it.split(',')[3].toDouble() < -100 }
.map { it.split(',')[1] }
tasks.register('describe') {
doLast {
println 'requested present? ' + requested.isPresent()
println 'requested orNull = ' + requested.orNull
println 'requested getOrElse = ' + requested.getOrElse('(none)')
println 'station id = ' + stationId.get()
println 'station name = ' + upper.get()
println 'west of 100 W = ' + western.isPresent()
}
}
tasks.register('missing') {
doLast { println requested.get() }
}
The Kotlin task copies the providers into local val`s before `doLast, so the lambda does not capture the script object (which the configuration cache cannot
serialize); the Groovy closure captures the script variables directly and the configuration cache accepted both. Without a property, then with -Pstation=BOS:
./gradlew describe --configuration-cache -q
./gradlew describe -Pstation=BOS --configuration-cache -q
requested present? false
requested orNull = null
requested getOrElse = (none)
station id = SF
station name = SAN FRANCISCO
west of 100 W = true
requested present? true
requested orNull = BOS
requested getOrElse = BOS
station id = BOS
station name = BOSTON
west of 100 W = false
The Groovy DSL printed exactly the same lines. San Francisco lies west of 100 degrees west and Boston does not, so filter leaves western without a value for BOS.
Both builds stored a configuration cache entry and reused it on a second run. Asking a provider for a value that is not there fails with a message that names the
source:
./gradlew missing --configuration-cache -q
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':missing' (registered in build file 'build.gradle.kts').
> Cannot query the value of Gradle property 'station' because it has no value available.
BUILD FAILED in 729ms
The Groovy DSL run printed the same message, preceded by a Where: line with the build file and the line number, and with build.gradle in place of build.gradle.kts.
The distinction between map and flatMap matters when the lambda’s result is itself a provider. map { it.outputFile } on a Provider<Task> would produce a
Provider<RegularFileProperty>, which is not what any input accepts; flatMap { it.outputFile } produces a Provider<RegularFile> and, as the next section shows,
keeps the information about which task produced the file. The official best practices list four rules for this: do not call get() inside map or flatMap, do not
wrap a task output in a standalone provider { }, expose task outputs as annotated abstract getters, and use map for plain values and flatMap for provider-typed
ones.
Connecting properties and tasks
A Property can be set to another Provider, and from then on it follows that provider. Connecting an extension property to a task property (as the
Why a value should not be read at configuration time example did with announceLazy) means the build author can set the extension at any point and the task sees
the final value. The same mechanism connects tasks to each other.
Two tidepool tasks: extractNames reads the stations CSV and writes the station names to a file, and report reads that file. report is wired to the output of
extractNames with flatMap, and the build directory is changed at the end of the script to show that no path was copied. A third task, reportEager, reads the
same value with get() to show what that costs:
abstract class ExtractNames : DefaultTask() {
@get:InputFile
abstract val csv: RegularFileProperty
@get:OutputFile
abstract val names: RegularFileProperty
@TaskAction
fun extract() {
val lines = csv.get().asFile.readLines().drop(1)
names.get().asFile.writeText(lines.joinToString("\n") { it.split(",")[1] })
}
}
abstract class Report : DefaultTask() {
@get:InputFile
abstract val names: RegularFileProperty
@TaskAction
fun report() {
println("Stations:")
names.get().asFile.forEachLine { println(" $it") }
}
}
val extract = tasks.register<ExtractNames>("extractNames") {
csv = layout.projectDirectory.file("data/stations.csv")
names = layout.buildDirectory.file("names.txt")
}
tasks.register<Report>("report") {
// flatMap keeps the link to the producing task: no dependsOn is needed
names = extract.flatMap { it.names }
}
tasks.register<Report>("reportEager") {
// get() twice: the task is realized now and the path is frozen now
names = extract.get().names.get()
}
// The build directory is changed after the wiring was written.
layout.buildDirectory = layout.projectDirectory.dir("out")
abstract class ExtractNames extends DefaultTask {
@InputFile
abstract RegularFileProperty getCsv()
@OutputFile
abstract RegularFileProperty getNames()
@TaskAction
void extract() {
def lines = csv.get().asFile.readLines().drop(1)
names.get().asFile.text = lines.collect { it.split(',')[1] }.join('\n')
}
}
abstract class Report extends DefaultTask {
@InputFile
abstract RegularFileProperty getNames()
@TaskAction
void report() {
println 'Stations:'
names.get().asFile.eachLine { println " $it" }
}
}
def extract = tasks.register('extractNames', ExtractNames) {
csv = layout.projectDirectory.file('data/stations.csv')
names = layout.buildDirectory.file('names.txt')
}
tasks.register('report', Report) {
// flatMap keeps the link to the producing task: no dependsOn is needed
names = extract.flatMap { it.names }
}
tasks.register('reportEager', Report) {
// get() twice: the task is realized now and the path is frozen now
names = extract.get().names.get()
}
// The build directory is changed after the wiring was written.
layout.buildDirectory = layout.projectDirectory.dir('out')
There is no dependsOn anywhere. A dry run shows that asking for report also schedules extractNames, and a real run executes both in the right order:
./gradlew report --dry-run
./gradlew report --configuration-cache
:extractNames SKIPPED
:report SKIPPED
BUILD SUCCESSFUL in 688ms
Calculating task graph as no cached configuration is available for tasks: report
> Task :extractNames
> Task :report
Stations:
San Francisco
Boston
Honolulu
BUILD SUCCESSFUL in 723ms
2 actionable tasks: 2 executed
Configuration cache entry stored.
A second run reused the configuration cache entry and printed :extractNames UP-TO-DATE, because the output file existed and its inputs had not changed:
Reusing configuration cache.
> Task :extractNames UP-TO-DATE
> Task :report
Stations:
San Francisco
Boston
Honolulu
BUILD SUCCESSFUL in 664ms
2 actionable tasks: 1 executed, 1 up-to-date
Configuration cache entry reused.
The Groovy DSL builds behaved the same way. Both are compatible with the configuration cache.
The dependency is inferred because extractNames.names is a task output property: it knows which task produces its value, and every provider derived from it with
flatMap or map inherits that knowledge. When such a provider is used as a task input, Gradle adds the task dependency itself. The official lazy-configuration page
shows the same for non-file inputs, where a String input is derived with producer.flatMap { it.outputFile }.map { it.asFile.readText() }; since Gradle 5.6 the
dependency is honored for any @Input property of type Provider.
reportEager is the opposite. extract.get() forces the extractNames task to be realized while the script is running, and .names.get() turns the property into a
plain RegularFile that no longer carries the producing task. After removing the out directory, the dry run schedules only reportEager, and the real run fails
because nothing creates the file:
./gradlew reportEager --dry-run
./gradlew reportEager
:reportEager SKIPPED
BUILD SUCCESSFUL in 683ms
> Task :reportEager FAILED
FAILURE: Build failed with an exception.
* What went wrong:
A problem was found with the configuration of task ':reportEager' (type 'Build_gradle.Report').
Input file does not exist
Type 'Build_gradle.Report' property 'names' specifies file '<work>/out/names.txt' which doesn't exist
An input file was expected to be present but it doesn't exist
For more information, please refer to https://docs.gradle.org/9.8.1/userguide/validation_problems.html#input_file_does_not_exist in the Gradle documentation.
Possible solutions:
1. Make sure the file exists before the task is called.
2. Make sure that the task which produces the file is declared as an input.
BUILD FAILED in 677ms
1 actionable task: 1 executed
In the Groovy DSL, the same run failed with the same message, only the type name was 'Report' instead of 'Build_gradle.Report'. This is the classic form of the
mistake that the section on get() below returns to: the value was correct, the task dependency was lost.
Files and directories
Files and directories have their own property types, because a File says nothing about whether it is a file or a directory and carries no task dependency:
RegularFileProperty and DirectoryProperty, with the read-only RegularFile and Directory as their values. Their values are relative to something, and that
something is a provider too, so the location follows the build directory when the build directory is changed (as the build in "Connecting properties and tasks" did with layout.buildDirectory =):
-
layout.projectDirectoryandlayout.buildDirectory(aDirectoryProperty) are the two roots.layout.buildDirectory.file("names.txt")is aProvider<RegularFile>, and.dir("x")aProvider<Directory>; both follow later changes. -
DirectoryProperty.file(path)and.dir(path)give lazily resolved children;.files(...)gives aFileCollection;.asFileTreegives aFileTreeof the directory’s contents. -
A
FileTreehas no stable order, so it should not be used where order matters. -
providers.fileContents(file)reads a file lazily, as text or bytes, and the configuration cache tracks it as an input (it was used in the previous section). Reading the same file withreadText()while the script is configured is an eager read.
Copying, archiving and the file-tree types in detail are on Working with Files.
Source: Gradle: Working with files
Collections and maps
Collection-valued properties have their own types, so that several contributors can add to the same value without knowing about each other. ListProperty<T> and
SetProperty<T> offer add(element), add(provider), addAll(...) and set(...); MapProperty<K, V> offers put(key, value), put(key, provider),
putAll(map) and set(...). Each addition is lazy, and the collection is computed when it is queried. The example adds a literal element, a lazily computed list, an
element that may be absent, a duplicated set element, and map entries whose value is only known later:
abstract class Summary : DefaultTask() {
@get:Input
abstract val ids: ListProperty<String>
@get:Input
abstract val tags: SetProperty<String>
@get:Input
abstract val limits: MapProperty<String, Double>
@TaskAction
fun summarize() {
println("ids = ${ids.get()}")
println("tags = ${tags.get()}")
println("limits = ${limits.get()}")
}
}
var maxHeight = 0.0 // not known yet when the task is configured
tasks.register<Summary>("summary") {
ids.add("SF") // one element
ids.addAll(providers.provider { listOf("BOS", "HNL") }) // a lazily computed list
ids.add(providers.gradleProperty("extraStation")) // an element that may be absent
tags.addAll("harmonic", "tidal", "harmonic") // a set drops the duplicate
limits.put("min", 0.0)
limits.put("max", providers.provider { maxHeight }) // evaluated when the task runs
limits.putAll(mapOf("alarm" to 4.5))
}
maxHeight = 5.25
abstract class Summary extends DefaultTask {
@Input
abstract ListProperty<String> getIds()
@Input
abstract SetProperty<String> getTags()
@Input
abstract MapProperty<String, Double> getLimits()
@TaskAction
void summarize() {
println "ids = ${ids.get()}"
println "tags = ${tags.get()}"
println "limits = ${limits.get()}"
}
}
double maxHeight = 0.0 // not known yet when the task is configured
tasks.register('summary', Summary) {
ids.add('SF') // one element
ids.addAll(providers.provider { ['BOS', 'HNL'] }) // a lazily computed list
ids.add(providers.gradleProperty('extraStation')) // an element that may be absent
tags.addAll('harmonic', 'tidal', 'harmonic') // a set drops the duplicate
limits.put('min', 0.0d)
limits.put('max', providers.provider { maxHeight }) // evaluated when the task runs
limits.putAll([alarm: 4.5d])
}
maxHeight = 5.25d
./gradlew summary --configuration-cache -PextraStation=NY
Calculating task graph as no cached configuration is available for tasks: summary
> Task :summary
ids = [SF, BOS, HNL, NY]
tags = [harmonic, tidal]
limits = {min=0.0, max=5.25, alarm=4.5}
BUILD SUCCESSFUL in 722ms
1 actionable task: 1 executed
Configuration cache entry stored.
The Groovy DSL printed limits = [min:0.0, max:5.25, alarm:4.5], the Groovy rendering of a map, and the other two lines unchanged. The value of maxHeight was
assigned after the task was configured and it was still the one used. The element providers.gradleProperty("extraStation") has a value only when the property is
given. Without it the whole ids property has no value, and because it is an input without @Optional the task is rejected before its action runs (no configuration
cache was used for this run):
./gradlew summary
> Task :summary FAILED
FAILURE: Build failed with an exception.
* What went wrong:
A problem was found with the configuration of task ':summary' (type 'Build_gradle.Summary').
Value not set
Type 'Build_gradle.Summary' property 'ids' doesn't have a configured value
This property isn't marked as optional and no value has been configured
For more information, please refer to https://docs.gradle.org/9.8.1/userguide/validation_problems.html#value_not_set in the Gradle documentation.
Possible solutions:
1. Assign a value to 'ids'.
2. Mark property 'ids' as optional.
BUILD FAILED in 698ms
1 actionable task: 1 executed
The Groovy DSL run failed in the same way, with the type shown as 'Summary'. An absent element makes the collection absent; use
ids.add(providers.gradleProperty("extraStation").orElse("none")) (not run here) or add a provider that always has a value if the element should be optional. Both builds
are compatible with the configuration cache.
A Property that holds a collection is the wrong tool, and Gradle refuses it. Declaring Property<List<String>> as an abstract getter fails when the task is created:
FAILURE: Build failed with an exception.
* What went wrong:
Could not determine the dependencies of task ':bad'.
> Creating a property of type 'Property<List<..>>' is unsupported. Use 'ListProperty<..>' instead.
BUILD FAILED in 695ms
The Groovy DSL printed the same message. The reasons are on the best practices page: a Property treats its value as one opaque unit, so a contributor has to read the
current collection with get() at configuration time and write a new one back, which loses laziness and the contributions of everyone else.
Conventions and finalizing
A convention is a default that is used while no explicit value has been set. A plugin applies it, a build script overrides it with set, and unset() returns to it.
Setting a convention never overrides an explicit value, so a plugin does not have to run first or last. A property can also take its convention at its declaration; the
Tides task in the example does that in its constructor. The task below shows each step and then freezes the value with finalizeValue():
abstract class Tides : DefaultTask() {
@get:Input
abstract val interval: Property<Int> // minutes between predictions
@get:Input
abstract val unit: Property<String>
// a convention declared next to the property
init {
unit.convention("m")
}
@TaskAction
fun run() {
println("interval = ${interval.get()} ${unit.get()}")
}
}
val tides = tasks.register<Tides>("tides") {
interval.convention(60) // default from a plugin or the build
}
tides {
println("1. before any set: ${interval.get()}")
interval.set(30) // explicit value wins over the convention
println("2. after set(30): ${interval.get()}")
interval.unset() // back to the convention
println("3. after unset(): ${interval.get()}")
interval.set(10)
interval.finalizeValue() // freeze the value now
println("4. finalized: ${interval.get()}")
try {
interval.set(5)
} catch (e: IllegalStateException) {
println("5. ${e.message}")
}
}
tasks.register<Tides>("late") {
interval = 15
doFirst {
interval = 1 // too late: Gradle finalizes task properties when the task starts
}
}
abstract class Tides extends DefaultTask {
@Input
abstract Property<Integer> getInterval() // minutes between predictions
@Input
abstract Property<String> getUnit()
Tides() {
// a convention declared next to the property
unit.convention('m')
}
@TaskAction
void run() {
println "interval = ${interval.get()} ${unit.get()}"
}
}
def tides = tasks.register('tides', Tides) {
interval.convention(60) // default from a plugin or the build
}
tides.configure {
println "1. before any set: ${interval.get()}"
interval.set(30) // explicit value wins over the convention
println "2. after set(30): ${interval.get()}"
interval.unset() // back to the convention
println "3. after unset(): ${interval.get()}"
interval.set(10)
interval.finalizeValue() // freeze the value now
println "4. finalized: ${interval.get()}"
try {
interval.set(5)
} catch (IllegalStateException e) {
println "5. ${e.message}"
}
}
tasks.register('late', Tides) {
interval = 15
doFirst {
interval = 1 // too late: Gradle finalizes task properties when the task starts
}
}
./gradlew tides --configuration-cache
Calculating task graph as no cached configuration is available for tasks: tides
1. before any set: 60
2. after set(30): 30
3. after unset(): 60
4. finalized: 10
5. The value for task ':tides' property 'interval' is final and cannot be changed any further.
> Task :tides
interval = 10 m
BUILD SUCCESSFUL in 686ms
1 actionable task: 1 executed
Configuration cache entry stored.
The Groovy DSL printed the same lines. The convention (60) is used until a value is set; unset() goes back to it; finalizeValue() computes the value and makes
further changes fail with an IllegalStateException whose message names the task and the property. Both are compatible with the configuration cache. The methods
come from HasConfigurableValue and Property:
| Method | Effect |
|---|---|
|
The value used until one is set. Calling it again replaces the convention; |
|
Sets an explicit value, replacing whatever was there. |
|
Discards the explicit value, so the convention applies again. |
|
Computes the final value now, replacing a provider with its result, and disallows further changes. |
|
The same, but the value is computed on the first read. It suits values that are expensive or may not be configured yet and that every reader must see identically. |
|
Disallows direct changes without computing the value, so the source of the value is still used until the value is finalized. Since Gradle 5.6. |
|
Reading the value during project configuration fails, so every plugin has a chance to configure it; the property is also finalized on read. Since Gradle 6.4. |
The last two methods were taken from the Javadoc and not run here. Gradle finalizes all of a task’s properties itself when the task starts running, which is why
assigning inside doFirst fails. The late task makes a change in the action:
./gradlew late --configuration-cache
Calculating task graph as no cached configuration is available for tasks: late
> Task :late FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':late' (registered in build file 'build.gradle.kts').
> The value for task ':late' property 'interval' is final and cannot be changed any further.
BUILD FAILED in 697ms
1 actionable task: 1 executed
Configuration cache entry stored.
The Groovy DSL run printed the same message, preceded by the Where: line with the build file and line number. The failure is deliberate: once the task has started, the
up-to-date check and the build cache key rely on the values read at that point, and a value changed afterwards would make them wrong.
ProviderFactory: Gradle properties, environment, system properties and processes
ProviderFactory is the source of providers that read the outside world. It is available in build scripts as providers, and can be injected anywhere else (see
Service injection). What a build reads through it is read lazily, and the configuration cache knows about it.
| Provider | Reads |
|---|---|
|
A Gradle property from |
|
A JVM system property of the build process |
|
An environment variable |
|
The text or bytes of a file |
|
The output of an external process, since Gradle 7.5 |
|
A custom |
|
A value computed by a lambda; prefer the others, as the Gradle documentation advises |
The example builds a banner from an external process, an environment variable with a fallback and a system property, combined with zip:
abstract class Banner : DefaultTask() {
@get:Input
abstract val text: Property<String>
@TaskAction
fun show() = println(text.get())
}
val environment = providers.environmentVariable("TIDEPOOL_ENV").orElse("dev")
val javaRelease = providers.systemProperty("java.specification.version")
val greeting = providers.exec {
commandLine("echo", "tidepool")
}.standardOutput.asText.map { it.trim() }
tasks.register<Banner>("banner") {
text = greeting.zip(environment) { g, e -> "$g running in $e" }
.zip(javaRelease) { s, j -> "$s on Java $j" }
}
abstract class Banner extends DefaultTask {
@Input
abstract Property<String> getText()
@TaskAction
void show() { println text.get() }
}
def environment = providers.environmentVariable('TIDEPOOL_ENV').orElse('dev')
def javaRelease = providers.systemProperty('java.specification.version')
def greeting = providers.exec {
commandLine('echo', 'tidepool')
}.standardOutput.asText.map { it.trim() }
tasks.register('banner', Banner) {
text = greeting.zip(environment) { g, e -> "$g running in $e".toString() }
.zip(javaRelease) { s, j -> "$s on Java $j".toString() }
}
./gradlew banner --configuration-cache
./gradlew banner --configuration-cache
TIDEPOOL_ENV=prod ./gradlew banner --configuration-cache
Calculating task graph as no cached configuration is available for tasks: banner
> Task :banner
tidepool running in dev on Java 26
BUILD SUCCESSFUL in 685ms
1 actionable task: 1 executed
Configuration cache entry stored.
Reusing configuration cache.
> Task :banner
tidepool running in dev on Java 26
BUILD SUCCESSFUL in 740ms
1 actionable task: 1 executed
Configuration cache entry reused.
Reusing configuration cache.
> Task :banner
tidepool running in prod on Java 26
BUILD SUCCESSFUL in 672ms
1 actionable task: 1 executed
Configuration cache entry reused.
The Groovy DSL builds printed the same banners. Both are compatible with the configuration cache. In this run the changed environment variable did not need a
reconfiguration: the third run reused the entry and printed prod, because the value is read when the task runs and not while the script is being configured. A
value read with .get() at configuration time would instead become an input of the configuration cache entry, and a changed value would have discarded it; that
behavior is described in the configuration cache requirements and is not run here.
Running an external process any other way at configuration time is deprecated in the 9.x line, whether through ExecOperations, ProcessBuilder, Runtime.exec or
Groovy’s String.execute(). The deprecation also says it applies even when the configuration cache is not enabled and that it becomes an error in Gradle 11;
providers.exec, providers.javaexec and a ValueSource that runs a process remain the supported ways, and the preferred one is to move the process into a task.
ObjectFactory, managed properties and managed types
ObjectFactory (in a script: objects) creates the objects a model is made of: property, listProperty, setProperty, mapProperty, fileProperty,
directoryProperty, fileCollection, fileTree, domainObjectContainer and newInstance, which creates a class with its constructor arguments and services injected.
It is the way a plugin creates a lazy model without implementing any of the property interfaces.
For most types there is a shorter way. Declare an abstract getter (or an abstract val in Kotlin) of a lazy type, and Gradle generates the implementation and manages
the state. The conditions are the ones of a JavaBeans property: the getter is named getFoo, it has no setter and it is public or protected. A type whose
properties are all managed is a managed type; it can be a class or an interface, it has no fields, and Gradle supplies the implementation. A nested managed type, used by
a task or an extension as a property, is declared with an abstract getter annotated @Nested. If the type contains an abstract String property called name, Gradle
fills it in and adds a name parameter to each constructor, which is what makes Named types the elements of a NamedDomainObjectContainer.
The next build uses all of that: a nested Constituent type, a named Station type in a container, an extension created with extensions.create (whose constructor
gets an ObjectFactory), and a task that has a nested property, a property that receives the container’s names, and an injected ObjectFactory used to create a
throw-away instance in the action:
import javax.inject.Inject
// A managed nested type: Gradle supplies the implementation
interface Constituent {
@get:Input
val speed: Property<Double>
@get:Input
val amplitude: Property<Double>
}
// A named managed type for use in a container
interface Station : Named {
val latitude: Property<Double>
}
// An extension: its properties are all managed
abstract class TidepoolExtension @Inject constructor(objects: ObjectFactory) {
abstract val title: Property<String>
abstract val outputDir: DirectoryProperty
@get:Nested
abstract val primary: Constituent // nested, read-only property, created by Gradle
val stations: NamedDomainObjectContainer<Station> =
objects.domainObjectContainer(Station::class.java)
fun primary(action: Action<in Constituent>) = action.execute(primary)
}
abstract class Describe : DefaultTask() {
// property injection: Gradle supplies the service
@get:Inject
abstract val objects: ObjectFactory
@get:Input
abstract val title: Property<String>
@get:Nested
abstract val primary: Constituent
@get:Input
abstract val stationNames: SetProperty<String>
@TaskAction
fun run() {
val extra = objects.newInstance<Constituent>() // a throw-away managed instance
extra.speed.set(primary.speed.get() * 2)
println("${title.get()}: M2 speed ${primary.speed.get()}, doubled ${extra.speed.get()}")
println("stations: ${stationNames.get()}")
}
}
val tidepool = extensions.create<TidepoolExtension>("tidepool")
tidepool.title.convention("Tidepool")
tidepool.outputDir.convention(layout.buildDirectory.dir("tidepool"))
tidepool.stations.register("SF") { latitude = 37.8063 }
tidepool.stations.register("BOS") { latitude = 42.3539 }
tidepool.primary { speed = 28.984; amplitude = 0.55 }
tasks.register<Describe>("describe") {
title = tidepool.title
primary.speed = tidepool.primary.speed
primary.amplitude = tidepool.primary.amplitude
stationNames = tidepool.stations.names
}
import javax.inject.Inject
// A managed nested type: Gradle supplies the implementation
interface Constituent {
@Input
Property<Double> getSpeed()
@Input
Property<Double> getAmplitude()
}
// A named managed type for use in a container
interface Station extends Named {
Property<Double> getLatitude()
}
// An extension: its properties are all managed
abstract class TidepoolExtension {
abstract Property<String> getTitle()
abstract DirectoryProperty getOutputDir()
@Nested
abstract Constituent getPrimary() // nested, read-only property, created by Gradle
final NamedDomainObjectContainer<Station> stations
@Inject
TidepoolExtension(ObjectFactory objects) {
stations = objects.domainObjectContainer(Station)
}
void primary(Action<? super Constituent> action) { action.execute(primary) }
}
abstract class Describe extends DefaultTask {
// property injection: Gradle supplies the service
@Inject
abstract ObjectFactory getObjects()
@Input
abstract Property<String> getTitle()
@Nested
abstract Constituent getPrimary()
@Input
abstract SetProperty<String> getStationNames()
@TaskAction
void run() {
def extra = objects.newInstance(Constituent) // a throw-away managed instance
extra.speed.set(primary.speed.get() * 2)
println "${title.get()}: M2 speed ${primary.speed.get()}, doubled ${extra.speed.get()}"
println "stations: ${stationNames.get()}"
}
}
def tidepool = extensions.create('tidepool', TidepoolExtension)
tidepool.title.convention('Tidepool')
tidepool.outputDir.convention(layout.buildDirectory.dir('tidepool'))
tidepool.stations.register('SF') { latitude = 37.8063d }
tidepool.stations.register('BOS') { latitude = 42.3539d }
tidepool.primary { speed = 28.984d; amplitude = 0.55d }
tasks.register('describe', Describe) {
title = tidepool.title
primary.speed = tidepool.primary.speed
primary.amplitude = tidepool.primary.amplitude
stationNames = tidepool.stations.names
}
./gradlew describe --configuration-cache
Calculating task graph as no cached configuration is available for tasks: describe
> Task :describe
Tidepool: M2 speed 28.984, doubled 57.968
stations: [BOS, SF]
BUILD SUCCESSFUL in 702ms
1 actionable task: 1 executed
Configuration cache entry stored.
The Groovy DSL printed the same lines. Both are compatible with the configuration cache and reused the entry on a second run. Two things were learned on the way and
are worth stating because the error messages are not obvious. First, abstract val primary: Constituent on the extension was rejected with Cannot have abstract method TidepoolExtension.getPrimary() until it was annotated @Nested. Second, the Constituent getters need @Input: as a nested type of a task they are task
inputs, and Gradle failed the task with property 'primary.speed' is missing an input or output annotation until they got one. stations.names is not a provider:
NamedDomainObjectCollection.getNames() returns a plain SortedSet<String>, so stationNames = tidepool.stations.names sets an eager value, read when the task is
configured, and its natural order is why the output is [BOS, SF] and not the registration order.
The old style, a plain Java bean with a private field and getter and setter methods, still works but the documentation calls it legacy and discourages it: it cannot be wired, it cannot carry a task dependency, and it is evaluated when it is set.
Service injection
Gradle exposes services (file operations, process execution, worker scheduling, ObjectFactory, ProjectLayout, ProviderFactory, BuildFeatures and more) that custom
types request instead of constructing, because the instances depend on internal state. A type declares the dependency and Gradle supplies it, for anything Gradle
instantiates: tasks, plugins, extensions, build services, value sources and worker actions. There are two forms:
-
constructor injection, with
@Injecton the constructor of an abstract class, which is the usual form for tasks and plugins; -
property injection, with
@Injecton an abstract getter (theobjectsproperty ofDescribein the previous example), which also works for interfaces.
The Locate task asks for a ProviderFactory and a ProjectLayout in its constructor, and uses them in its action. No Project is needed at execution time, which
is what the configuration cache requires:
import javax.inject.Inject
abstract class Locate @Inject constructor(
private val providers: ProviderFactory,
private val layout: ProjectLayout
) : DefaultTask() {
@TaskAction
fun locate() {
val station = providers.gradleProperty("station").getOrElse("SF")
val build = layout.buildDirectory.get().asFile.relativeTo(layout.projectDirectory.asFile)
println("station $station, output in ./$build")
}
}
tasks.register<Locate>("locate")
import javax.inject.Inject
abstract class Locate extends DefaultTask {
private final ProviderFactory providers
private final ProjectLayout layout
@Inject
Locate(ProviderFactory providers, ProjectLayout layout) {
this.providers = providers
this.layout = layout
}
@TaskAction
void locate() {
def station = providers.gradleProperty('station').getOrElse('SF')
def build = layout.projectDirectory.asFile.toPath().relativize(layout.buildDirectory.get().asFile.toPath())
println "station ${station}, output in ./${build}"
}
}
tasks.register('locate', Locate)
./gradlew locate -Pstation=BOS --configuration-cache
Calculating task graph as no cached configuration is available for tasks: locate
> Task :locate
station BOS, output in ./build
BUILD SUCCESSFUL in 725ms
1 actionable task: 1 executed
Configuration cache entry stored.
The Groovy DSL printed the same lines. Both are compatible with the configuration cache and reused the entry on a second run. In the Kotlin DSL a task class with an
@Inject constructor is registered with tasks.register<Locate>("locate") and no arguments, because Gradle supplies the injected ones; extra constructor arguments
are passed after the name.
In a build script, project, objects, providers, layout and files(...) are available directly, but a script is not a place to hold state for the task action.
Reading an injected Project or Gradle service from a task action is deprecated in 9.x and becomes an error in Gradle 10 (with the configuration cache enabled it is
reported as a configuration cache problem); declare the values the action needs as task inputs instead.
Registering tasks lazily
Properties are lazy values; the task containers have lazy counterparts too. Task configuration avoidance means that a task is only created and configured if the build needs it. The eager forms do the work for every task in every build. The old and new APIs, from the official overview:
| Eager API to avoid | Lazy API | Difference |
|---|---|---|
|
|
|
|
|
|
|
|
Adding a closure to |
|
|
|
The first build uses only lazy calls. Of the two registered tasks, only the requested one is configured, and the named block for a task that is never needed does not run:
abstract class Probe : DefaultTask() {
@TaskAction
fun run() = println("running $path")
}
tasks.register<Probe>("lazyOne") { println("configuring lazyOne") }
tasks.register<Probe>("lazyTwo") { println("configuring lazyTwo") }
tasks.withType<Probe>().configureEach { println("configureEach sees $name") }
tasks.named("lazyTwo") { println("named block for lazyTwo") }
abstract class Probe extends DefaultTask {
@TaskAction
void run() { println "running ${path}" }
}
tasks.register('lazyOne', Probe) { println 'configuring lazyOne' }
tasks.register('lazyTwo', Probe) { println 'configuring lazyTwo' }
tasks.withType(Probe).configureEach { println "configureEach sees ${name}" }
tasks.named('lazyTwo') { println "named block for lazyTwo" }
./gradlew help -q
./gradlew lazyOne -q
gradle help printed only the standard help text, without a single configuring line:
Welcome to Gradle 9.8.1.
To run a build, run gradlew <task> ...
To see a list of available tasks, run gradlew tasks
To see more detail about a task, run gradlew help --task <task>
To see a list of command-line options, run gradlew --help
For more detail on using Gradle, see https://docs.gradle.org/9.8.1/userguide/command_line_interface.html
For troubleshooting, visit https://help.gradle.org
configuring lazyOne
configureEach sees lazyOne
running :lazyOne
The Groovy DSL builds printed the same. A build with one eager call mixed in loses all of it. The second build adds tasks.create and tasks.withType<Probe> { }
to the same tasks, and gradle help then configured every task, including the ones registered lazily:
abstract class Probe : DefaultTask() {
@TaskAction
fun run() = println("running $path")
}
// Eager: create() runs the block now, withType {} touches every existing Probe
tasks.create<Probe>("eagerOne") { println("configuring eagerOne") }
tasks.withType<Probe> { println("withType sees $name") }
// Lazy: register() and configureEach {} wait until a task is needed
tasks.register<Probe>("lazyOne") { println("configuring lazyOne") }
tasks.register<Probe>("lazyTwo") { println("configuring lazyTwo") }
tasks.withType<Probe>().configureEach { println("configureEach sees $name") }
tasks.named("lazyTwo") { println("named block for lazyTwo") }
abstract class Probe extends DefaultTask {
@TaskAction
void run() { println "running ${path}" }
}
// Eager: create() runs the block now, withType {} touches every existing Probe
tasks.create('eagerOne', Probe) { println 'configuring eagerOne' }
tasks.withType(Probe) { println "withType sees ${name}" }
// Lazy: register() and configureEach {} wait until a task is needed
tasks.register('lazyOne', Probe) { println 'configuring lazyOne' }
tasks.register('lazyTwo', Probe) { println 'configuring lazyTwo' }
tasks.withType(Probe).configureEach { println "configureEach sees ${name}" }
tasks.named('lazyTwo') { println "named block for lazyTwo" }
./gradlew help -q
configuring eagerOne
withType sees eagerOne
withType sees lazyOne
configuring lazyOne
withType sees lazyTwo
configuring lazyTwo
configureEach sees eagerOne
configureEach sees lazyOne
configureEach sees lazyTwo
named block for lazyTwo
Welcome to Gradle 9.8.1.
To run a build, run gradlew <task> ...
To see a list of available tasks, run gradlew tasks
To see more detail about a task, run gradlew help --task <task>
To see a list of command-line options, run gradlew --help
For more detail on using Gradle, see https://docs.gradle.org/9.8.1/userguide/command_line_interface.html
For troubleshooting, visit https://help.gradle.org
The Groovy DSL printed the same lines. The eager withType { } realized lazyOne and lazyTwo as soon as they were registered, and ran the register blocks in
that process; one eager API on a type is enough to undo laziness for all its tasks. Both builds are compatible with the configuration cache. Because create is
deprecated in the Kotlin DSL, the compiler warns about it; the Gradle documentation lists the eager APIs to avoid.
The classic bug: Provider.get() outside a task action
Every section above ends on the same rule, and the official best practices state it as one: do not call get() on a provider outside a task action. When configuring
tasks and extensions, use map or flatMap instead. Calling get() while a script is being configured has three consequences, taken from the page:
-
the value becomes an input of configuration, so a change can cost a configuration cache miss;
-
the value may be read too early, before another plugin or the script itself has set the final value (the first example on this page);
-
the dependency information is lost, so Gradle cannot infer task dependencies or track inputs and outputs (the
reportEagerexample).
The same rule has companions: do not use layout.buildDirectory.get().asFile where layout.buildDirectory.file("x") fits; do not resolve a Configuration before
execution; do not keep the result of a provider in a script variable that a task action reads; and do not access project at execution time. A quick audit of a
build: search the scripts and plugins for .get() and .getOrElse outside doLast, doFirst, @TaskAction and lambdas passed to map, flatMap and zip.
The Kotlin side adds a type-level rule: since Gradle 9.0, the API carries JSpecify nullability annotations, so Property<String?> no longer compiles and a Kotlin
extension on Provider<T> has to declare T : Any. Use a property with no value in place of a nullable value.
Guidelines for plugin and task authors
The user manual gives these rules for exposing lazy properties in types that other people use:
-
For a configurable value, expose the
Propertydirectly through a single getter. For a computed value, expose aProviderdirectly through a single getter. -
Do not add extra getters and setters that wrap
obj.getProperty().get()andobj.getProperty().set(T); they read the value immediately and prevent wiring. -
When migrating a plugin, expose a new property as a
PropertyorProvider; for an existing stable property, add the new one and deprecate the old, wiring the old getter and setter into the new property. -
Use the dedicated collection and file types, not a
Propertyof a collection, aFileor aStringpath. -
Prefer to set conventions in the plugin and let the build script override them.
Source: Gradle: Using the Provider API
Release history
| Release | Change |
|---|---|
4.0 (2017-06-14) |
The first provider types appear in the plugin API: an extension exposes a value as |
4.3 (2017-10-30) |
|
4.5 (2018-01-24) |
|
4.9 (2018-07-16) |
The lazy task API, |
5.1 (2019-01-02) |
|
5.6 (2019-08-14) |
|
6.6 (2020-08-10) |
|
7.5 (2022-07-14) |
|
8.2 (2023-06-30) |
|
8.5 (2023-11-29) |
|
9.0.0 (2025-07-31) |
JSpecify nullability annotations in the API: |
9.8.0 (2026-09-24) |
|
The entries come from the release notes of 4.0, 4.3, 4.5, 5.1, 5.6, 6.6, 7.5 and 8.2, from the Gradle version list for the dates, and from Gradle: Copy and Sync destination is now a lazy destinationDirectory property.
Ant, Maven and the lazy model
| Idea | Ant | Maven | Gradle |
|---|---|---|---|
When is a value computed? |
Properties are immutable, the first definition wins, and values are expanded when they are set; see Ant Properties |
Plugin parameters, including expressions such as |
In the configuration phase unless a |
Passing a result to the next step |
A file path in a property that both targets agree on |
A file in |
An output property connected to an input property; the task dependency follows |
Defaults and overrides |
First definition wins; later ones are ignored |
The parameter’s default, then the plugin configuration in the POM, or its user property from |
Conventions, then explicit values, then |
Typed plugin parameters |
Task attributes, strings |
|
|
The Maven comparison is the nearest one: a Mojo parameter is injected just before execution, so a Maven plugin never faces the "read too early" problem, while a Gradle script that mixes configuration and the computation of values has to take care of it. The other tools' notion of a "property" is a string; Gradle’s is an object that can be wired.
Gradle 8.14 and older builds
Everything on this page works on 8.14 with the exceptions below, from the release notes and the upgrade guides (not run here, because Gradle 8.14 was not available):
-
Provider.zip,orElse,MapProperty,convention()and the collection types exist long before 8.14.providers.exechas been available since 7.5, andProvider.filtersince 8.5, still incubating. -
The
=assignment of lazy properties in the Kotlin DSL works since 8.2; older scripts writeprop.set(value), which works in both lines. -
The deprecations described above (
ExecOperationsat configuration time, an injectedProjectin a task action) are announced in the 9.x line, as the upgrade guide says, and are not in 8.14. -
The strict nullability of
Property<T>in the Kotlin DSL is new in 9.0, with the JSpecify annotations. -
The configuration cache is stable since 8.1 and the preferred mode in 9.0, but it still has to be enabled in both lines; it is announced as the default of Gradle 10.
Where the books are outdated
Gradle in Action (2014) describes Gradle 1.7, which predates the whole Provider API (4.0 and later). Its model of build, project and task is still right; where it sets plain properties on a task, the current style is a lazy property.
| Book statement or practice | Current status | Source |
|---|---|---|
|
Removed in Gradle 5.0. Use |
|
Task properties are plain fields set by the build script; versions and paths are kept in |
Lazy |
|
|
|
|
No configuration cache; a task action may use |
The configuration cache is stable since 8.1; |
Summary
-
Model values as
Property(configurable) andProvider(read-only); aPropertycan be set to aProvider, which is how the model is wired before its values are known. -
Chain with
map,flatMap,zip,orElseandfilter, and callget()only in a task action or inside a lambda that is itself lazy. -
Connect task outputs to inputs with
flatMap; the task dependency is then inferred. An earlyget()loses it. -
Use
ListProperty,SetProperty,MapProperty,RegularFilePropertyandDirectoryProperty; an absent element makes a collection absent, andProperty<List<T>>is rejected. -
Use conventions for defaults,
setto override,finalizeValue()to freeze; Gradle finalizes a task’s properties when it starts. -
Read the outside world with
providers(gradleProperty,environmentVariable,systemProperty,fileContents,exec) rather than directly, which is eager. -
Declare properties as abstract getters (
@Nestedfor nested managed types,@Inputand friends for task inputs) and inject services with@Inject. -
Register tasks with
register, find them withnamed, and configure them withwithType<T>().configureEach; one eager call can undo the laziness of a whole type.