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:

Kotlin DSL (build.gradle.kts)
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"
Groovy DSL (build.gradle)
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.

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 a Provider can be derived from another one with map.

  • Property<T> extends Provider<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 a Property to a Provider is 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

Property<T>

objects.property(String::class) in the Kotlin DSL, objects.property(String) in the Groovy DSL, or an abstract getter on a task or extension

A read-only computed value

Provider<T>

provider.map { }, provider.flatMap { }, providers.provider { }

A configurable file or directory

RegularFileProperty, DirectoryProperty

objects.fileProperty(), objects.directoryProperty(), or an abstract getter

A read-only file or directory

Provider<RegularFile>, Provider<Directory>

layout.buildDirectory.file("x"), directoryProperty.dir("x")

A configurable list, set or map

ListProperty<T>, SetProperty<T>, MapProperty<K, V>

objects.listProperty(), objects.setProperty(), objects.mapProperty(), or an abstract getter

A set of files

ConfigurableFileCollection, ConfigurableFileTree

objects.fileCollection(), objects.fileTree()

A read-only set of files

FileCollection, FileTree

files(...), fileTree(...), directoryProperty.files(...), directoryProperty.asFileTree

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

map { }

A provider of the transformed value. When the source has no value, the result has no value either and the lambda is not called.

flatMap { }

The lambda returns another provider and the result is flattened. Use it when the transformation itself yields a Provider, such as a task output property.

zip(other) { a, b -> }

Combines two providers. Added in Gradle 6.6; if either side has no value, the result has none and the combiner is not called.

orElse(value) or orElse(provider)

A fallback used when the source has no value. Added in Gradle 5.6.

filter { }

The value only if the predicate holds, otherwise no value. Incubating, since Gradle 8.5.

isPresent, getOrElse(v), orNull, get()

Queries. get() fails when there is no value; use them in a task action, or inside another lambda that is itself lazy.

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:

Kotlin DSL (build.gradle.kts)
// 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()) }
}
Groovy DSL (build.gradle)
// 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:

Kotlin DSL (build.gradle.kts)
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")
Groovy DSL (build.gradle)
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.projectDirectory and layout.buildDirectory (a DirectoryProperty) are the two roots. layout.buildDirectory.file("names.txt") is a Provider<RegularFile>, and .dir("x") a Provider<Directory>; both follow later changes.

  • DirectoryProperty.file(path) and .dir(path) give lazily resolved children; .files(...) gives a FileCollection; .asFileTree gives a FileTree of the directory’s contents.

  • A FileTree has 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 with readText() while the script is configured is an eager read.

Copying, archiving and the file-tree types in detail are on 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:

Kotlin DSL (build.gradle.kts)
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
Groovy DSL (build.gradle)
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():

Kotlin DSL (build.gradle.kts)
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
    }
}
Groovy DSL (build.gradle)
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

convention(value) or convention(provider)

The value used until one is set. Calling it again replaces the convention; unsetConvention() removes it.

set(value) or set(provider), value(...)

Sets an explicit value, replacing whatever was there. value(...) is the same as set but returns the property, and passing null to it discards the value.

unset()

Discards the explicit value, so the convention applies again.

finalizeValue()

Computes the final value now, replacing a provider with its result, and disallows further changes.

finalizeValueOnRead()

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.

disallowChanges()

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.

disallowUnsafeRead()

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

providers.gradleProperty("x")

A Gradle property from -Px, gradle.properties in the project or in the Gradle user home, or ORG_GRADLE_PROJECT_x; see Build Environment & Properties

providers.systemProperty("x")

A JVM system property of the build process

providers.environmentVariable("X")

An environment variable

providers.fileContents(file)

The text or bytes of a file

providers.exec { } and providers.javaexec { }

The output of an external process, since Gradle 7.5

providers.of(MyValueSource::class) { }

A custom ValueSource for a more complex computation; the Worker API & Build Services page covers the related services

providers.provider { }

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:

Kotlin DSL (build.gradle.kts)
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" }
}
Groovy DSL (build.gradle)
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:

Kotlin DSL (build.gradle.kts)
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
}
Groovy DSL (build.gradle)
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 @Inject on the constructor of an abstract class, which is the usual form for tasks and plugins;

  • property injection, with @Inject on an abstract getter (the objects property of Describe in 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:

Kotlin DSL (build.gradle.kts)
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")
Groovy DSL (build.gradle)
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

task myTask(type: MyTask) { } (Groovy shorthand) and tasks.create("x") { }

tasks.register("x") { }

register stores a recipe and returns a TaskProvider; the block runs only if the task is needed.

tasks.getByName("x") { }

tasks.named("x") { }

named returns a TaskProvider and configures the task only if it is created anyway.

tasks.withType(T) { }

tasks.withType(T).configureEach { }

Adding a closure to withType makes it eager: every task of the type is created at once. configureEach applies the action to each task as it is created.

tasks.whenTaskAdded { }

tasks.configureEach { }

configureEach returns void, so it cannot be chained.

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:

Kotlin DSL (build.gradle.kts)
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") }
Groovy DSL (build.gradle)
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:

Kotlin DSL (build.gradle.kts)
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") }
Groovy DSL (build.gradle)
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 reportEager example).

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 Property directly through a single getter. For a computed value, expose a Provider directly through a single getter.

  • Do not add extra getters and setters that wrap obj.getProperty().get() and obj.getProperty().set(T); they read the value immediately and prevent wiring.

  • When migrating a plugin, expose a new property as a Property or Provider; 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 Property of a collection, a File or a String path.

  • Prefer to set conventions in the plugin and let the build script override them.

Release history

Release Change

4.0 (2017-06-14)

The first provider types appear in the plugin API: an extension exposes a value as Provider<String> and a task takes it as a Provider (the release-note example used the early PropertyState type).

4.3 (2017-10-30)

ListProperty, and the file properties RegularFileProperty and DirectoryProperty that carry the generating task, so Gradle infers task dependencies.

4.5 (2018-01-24)

SetProperty.

4.9 (2018-07-16)

The lazy task API, tasks.register and tasks.named.

5.1 (2019-01-02)

MapProperty and convention().

5.6 (2019-08-14)

Provider.orElse, and task dependencies honored for @Input properties of type Provider.

6.6 (2020-08-10)

Provider.zip.

7.5 (2022-07-14)

providers.exec and providers.javaexec, the configuration-cache-compatible way to run a process at configuration time.

8.2 (2023-06-30)

= assignment of lazy properties in the Kotlin DSL on by default.

8.5 (2023-11-29)

Provider.filter, incubating.

9.0.0 (2025-07-31)

JSpecify nullability annotations in the API: Property<String?> no longer compiles in the Kotlin DSL.

9.8.0 (2026-09-24)

Copy and Sync destinations become a lazy destinationDirectory property.

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 ${project.build.directory}, are resolved when the plugin goal is configured for execution; see Maven Plugins & Their Configuration

In the configuration phase unless a Provider defers it to the point where the task runs

Passing a result to the next step

A file path in a property that both targets agree on

A file in target/ that the next plugin reads

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 -D

Conventions, then explicit values, then finalizeValue()

Typed plugin parameters

Task attributes, strings

@Parameter fields of a Mojo; see Writing Maven Plugins

Property<T>, ListProperty<T>, RegularFileProperty

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.exec has been available since 7.5, and Provider.filter since 8.5, still incubating.

  • The = assignment of lazy properties in the Kotlin DSL works since 8.2; older scripts write prop.set(value), which works in both lines.

  • The deprecations described above (ExecOperations at configuration time, an injected Project in 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

task name << { }

Removed in Gradle 5.0. Use doLast { } and tasks.register.

Upgrading from 4.x

Task properties are plain fields set by the build script; versions and paths are kept in ext variables read while configuring

Lazy Property and Provider values, wired with conventions; values from the environment through providers

Configuring tasks lazily

task x(type: Copy) { } creates the task as soon as the script reaches it

tasks.register<Copy>("x") { } creates it only if the build needs it

Task configuration avoidance

No configuration cache; a task action may use project freely

The configuration cache is stable since 8.1; project in a task action is a problem

Configuration cache requirements

Summary

  • Model values as Property (configurable) and Provider (read-only); a Property can be set to a Provider, which is how the model is wired before its values are known.

  • Chain with map, flatMap, zip, orElse and filter, and call get() 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 early get() loses it.

  • Use ListProperty, SetProperty, MapProperty, RegularFileProperty and DirectoryProperty; an absent element makes a collection absent, and Property<List<T>> is rejected.

  • Use conventions for defaults, set to 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 (@Nested for nested managed types, @Input and friends for task inputs) and inject services with @Inject.

  • Register tasks with register, find them with named, and configure them with withType<T>().configureEach; one eager call can undo the laziness of a whole type.