Writing Custom Gradle Tasks

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

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

This page is written against Gradle 9.8.1 and was run on JDK 26 on 2026-10-16 (the daemon ran on JDK 26; no toolchain was involved, because the task reads a text file and does not compile anything). Every example was run through the wrapper in scratch copies of the tidepool project, once with Kotlin DSL build files and once with Groovy DSL build files, and the console output is the same in both apart from the timings, the task counts (the Kotlin buildSrc has one task more) and the build file name. The scratch copies pointed their wrapper at a local copy of the same distribution archive; the properties file that a real project commits is

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

The commands use --console=plain. In the listings the blank lines, the "Consider enabling configuration cache" hint, the link to the problems report and the Try: hints of failures are left out, the tasks of the buildSrc build are collapsed to the single line (buildSrc tasks omitted), and the scratch directory is shown as /work/tidepool. Examples run with --configuration-cache say so, and each example states whether it is compatible with the configuration cache. Gradle 8.14.x was not installed here, so statements about it come from the official documentation and are labelled "not run here".

Gradle ships tasks for the common jobs (Copy, Zip, Test, JavaCompile), and most builds only configure them. When no built-in task fits, you write a task class: a small class with typed inputs and outputs and one method that does the work. Declaring the inputs and outputs is what turns the class into something Gradle can order, skip when nothing changed, run in parallel, restore from the build cache and cache in the configuration cache; the work itself is the easy part. The Maven counterpart is a Mojo in a plugin (see Writing Maven Plugins), and the Ant counterpart is a Java class that extends Task (see Writing Custom Ant Tasks); the same tidepool station-file check is built on that page, so the three versions can be compared. The task concepts used below (the task graph, outcomes, register versus create) are on Gradle Tasks, and the Property and Provider types are on Lazy Configuration & Providers.

Three ways to define a task

Way What it looks like When to use it

Configure a built-in task type

tasks.register<Zip>("packageApp") { ... }

The job is archiving, copying, running a program or testing; see Working with Files.

Ad hoc task with doFirst and doLast

tasks.register("stationCount") { doLast { ... } }

A few lines of logic that live in one build script and are not reused.

Task class

abstract class CheckStations : DefaultTask() with a @TaskAction method

The logic is reused, tested, cached or has a non-trivial set of inputs and outputs.

The Gradle manual warns that the ad hoc style makes it easy to mix configuration-time and execution-time state and that its inputs are untyped; a class keeps the two apart, because the properties and annotations are configuration and the @TaskAction method is execution. The rest of this page is about the class.

The smallest task class

A task class extends DefaultTask, is declared abstract, declares each input as an abstract property, and has one method annotated with @TaskAction:

Kotlin DSL
import org.gradle.api.DefaultTask
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction

abstract class Hello : DefaultTask() {
    @get:Input
    abstract val station: Property<String>

    @TaskAction
    fun greet() {
        println("Hello, ${station.get()}")
    }
}

tasks.register<Hello>("hello") {
    station = "San Francisco"
}
Groovy DSL
import org.gradle.api.DefaultTask
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction

abstract class Hello extends DefaultTask {
    @Input
    abstract Property<String> getStation()

    @TaskAction
    void greet() {
        println "Hello, ${station.get()}"
    }
}

tasks.register('hello', Hello) {
    station = 'San Francisco'
}
$ ./gradlew hello --configuration-cache
Calculating task graph as no cached configuration is available for tasks: hello
> Task :hello
Hello, San Francisco
BUILD SUCCESSFUL in 897ms
1 actionable task: 1 executed
Configuration cache entry stored.

Run with --configuration-cache on Gradle 9.8.1 (JDK 26); the example is compatible with the configuration cache, and the Groovy DSL build prints the same lines. Source: Gradle User Manual: Task actions

Four things in these few lines carry most of the design:

  • The class is abstract. Gradle generates the implementation of every abstract property getter (a "managed property"), creates the Property<String> for you and gives the task a constructor it can call; you never write ObjectFactory.property(...) in a task. All the Gradle-provided property types work this way: Property<T>, ListProperty<T>, SetProperty<T>, MapProperty<K, V>, RegularFileProperty, DirectoryProperty and ConfigurableFileCollection. Since Gradle 9.0 every Gradle class with an @Inject getter has that getter declared abstract, so any subclass of such a class has to be abstract too; see Injection getters are now abstract.

  • Configuration code and execution code are separate. The properties and the block passed to register run during the configuration phase, when Gradle builds the model; the @TaskAction method runs in the execution phase, and only if the task is in the task graph and is neither up to date nor restored from the build cache (see Gradle Build Lifecycle).

  • In Kotlin the annotation goes on the getter. @get:Input puts @Input on the generated getter, which is where Gradle looks. Without get: Kotlin applies the annotation to a different element (or rejects it), and Gradle never sees it.

  • The assignment station = "San Francisco" works in the Kotlin DSL since Gradle 8.2 because Gradle turns = into set() for Property types; before 8.2 (not run here) it was station.set("San Francisco"), which still works in both DSLs.

Task actions

A task action is code that runs when the task executes. A class declares its main action with a @TaskAction method (a method that takes an InputChanges parameter makes the task incremental, which is covered on Incremental Build). Any task, including a built-in one, can add more actions from a build script with doFirst (runs before the existing actions) and doLast (runs after them). A task has a list of actions, which is why the methods have those names, and actions.clear() empties the list.

An ad hoc task is a DefaultTask (the type used when register gets no class) with doLast and its inputs and outputs declared through the task’s inputs and outputs properties. The equivalent of a very small task class for counting the stations in data/stations.csv:

Kotlin DSL
tasks.register("stationCount") {
    val csv = layout.projectDirectory.file("data/stations.csv")
    val out = layout.buildDirectory.file("station-count.txt")
    inputs.file(csv)
    outputs.file(out)
    doLast {
        val count = csv.asFile.readLines().size - 1
        out.get().asFile.writeText("$count\n")
        println("$count stations")
    }
}
Groovy DSL
tasks.register('stationCount') {
    def csv = layout.projectDirectory.file('data/stations.csv')
    def out = layout.buildDirectory.file('station-count.txt')
    inputs.file(csv)
    outputs.file(out)
    doLast {
        def count = csv.asFile.readLines().size() - 1
        out.get().asFile.text = "$count\n"
        println "$count stations"
    }
}
$ ./gradlew stationCount --configuration-cache
Calculating task graph as no cached configuration is available for tasks: stationCount
(buildSrc tasks omitted)
> Task :stationCount
3 stations
BUILD SUCCESSFUL in 1s
4 actionable tasks: 1 executed, 3 up-to-date
Configuration cache entry stored.

$ ./gradlew stationCount --configuration-cache
Reusing configuration cache.
> Task :stationCount UP-TO-DATE
BUILD SUCCESSFUL in 896ms
1 actionable task: 1 up-to-date
Configuration cache entry reused.

Both runs used --configuration-cache. The example is compatible with it because the doLast block captures only the two local values csv and out, not the project; it is not compatible if the block reads project or this of the build script (see Classic mistakes). The up-to-date check works because the inputs and the output are declared; without the outputs.file(out) line the task would be treated as always out of date.

Older Gradle versions (and the Gradle 1.x book this section’s outline cites) defined such tasks with the left-shift operator, task stationCount << { ... }. That syntax was removed in Gradle 5.0 (see Upgrading from 4.x to 5.0); write doLast inside tasks.register.

Declaring inputs and outputs with annotations

Gradle can only skip a task, cache it or order it against other tasks if it knows what the task reads and writes. A task class declares that with annotations on the getters. Every property of a task class must carry exactly one of the following annotations (or @Internal); Gradle fails the build when one is missing, as shown in Classic mistakes.

Annotation Meaning

@Input

A value (string, number, boolean, enum, a serializable object, or the content of a Property). Its value is part of the up-to-date check and the cache key.

@InputFile

One file; the content counts. Use it on a RegularFileProperty.

@InputFiles

A set of files, typically a ConfigurableFileCollection. The checkStations task below uses it.

@InputDirectory

A directory and everything under it; use it on a DirectoryProperty.

@Classpath

Files that form a Java runtime classpath. The order counts; the path of a JAR is ignored, and so are the timestamps and the order of entries inside it.

@CompileClasspath

A Java compile classpath: only changes that affect the API of the classes count. Changes to resources, manifests, private members and method bodies are ignored.

@Nested

A bean (here, an interface of properties) whose own getters carry annotations. Gradle instantiates an abstract getter of a nested type for you; the implementation of the bean is tracked as an input too.

@Optional

A modifier: the input or output may have no value. Without it a property with no value is an error.

@OutputFile, @OutputFiles

One output file, or several in a file collection.

@OutputDirectory, @OutputDirectories

One output directory, or several.

@Destroys

A file or directory that belongs to another task and that this task destroys (as clean does). The task gets exclusive access to it while it runs.

@LocalState

State kept between runs that is neither an input nor an output (for example a task’s own cache). It is removed when the outputs of a cached task are restored from the cache.

@Internal

Not an input or an output: changing it does not make the task out of date. It is inherently optional, and adding @Optional to it is an error.

@Console

A value that only affects what is printed; like @Internal.

@PathSensitive

How the path of an input file counts: ABSOLUTE, RELATIVE, NAME_ONLY or NONE. The default for file inputs is ABSOLUTE.

@SkipWhenEmpty

On a file input: when it is empty the task is skipped and reported as NO-SOURCE.

@IgnoreEmptyDirectories

Used with @InputFiles or @InputDirectory: only the files count, not the empty directories among them.

@NormalizeLineEndings

Used with @InputFiles, @InputDirectory or @Classpath: the line endings of text files are normalised before the fingerprint is computed.

@Incremental

On a file input of an incremental task: the changes to it can be queried through InputChanges.

Two further annotations describe the task type as a whole rather than one property:

Annotation Meaning

@CacheableTask

The task’s outputs may be stored in and restored from the build cache. Apply it only when the output is reproducible and relocatable.

@DisableCachingByDefault(because = "...")

The task type is not cacheable, and the reason is documented in the source. Build logic can still override the decision for one task.

@UntrackedTask(because = "...")

Gradle does not capture the state of the task at all: it is always out of date and is never cached. Use it when the task reads or writes things Gradle cannot track, such as a remote database.

The how and why of fingerprints, normalisation and the stale-output rules are on Incremental Build; the cache itself is on a later page of this section (Build Cache).

The tidepool checkStations task

The tidepool engine reads data/stations.csv: one station per row, with id, name, latitude, longitude and a semicolon-separated list of constituents. checkStations validates that file, like the Ant <checkstations> task on the Ant page (the Gradle version adds an upper limit on constituents): five columns per row, latitude within [-90, 90], longitude within [-180, 180], no duplicate ids, enough constituents, and every required id present. The contract is:

Property Meaning

stations

ConfigurableFileCollection, @InputFiles, relative-path sensitive, @SkipWhenEmpty: the station files to check.

limits

A @Nested bean with minConstituents (default 1) and maxConstituents (default 8).

required

SetProperty<String>, optional: station ids that must be present. Command-line option --require.

failOnError

Property<Boolean>, default true. Option --fail-on-error and its opposite --no-fail-on-error.

report

Property<ReportMode> (SUMMARY or FULL), default SUMMARY: whether the report lists the station ids. Option --report.

maxMessages

Property<Int>, @Internal, default 10: how many problems are logged. It does not change the report file, so it is not an input.

reportFile

RegularFileProperty, @OutputFile: where the counts are written.

The task class lives in a package of its own, io.tidepool.build. It is shown here as it sits in buildSrc; the next sections explain each annotation and where else the class could live.

buildSrc/src/main/kotlin/io/tidepool/build/CheckStations.kt (Kotlin DSL project)
package io.tidepool.build

import org.gradle.api.Action
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.provider.SetProperty
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.Nested
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.SkipWhenEmpty
import org.gradle.api.tasks.TaskAction
import org.gradle.api.tasks.options.Option

enum class ReportMode { SUMMARY, FULL }

interface Limits {
    @get:Input
    val minConstituents: Property<Int>

    @get:Input
    val maxConstituents: Property<Int>
}

@CacheableTask
abstract class CheckStations : DefaultTask() {

    @get:InputFiles
    @get:SkipWhenEmpty
    @get:PathSensitive(PathSensitivity.RELATIVE)
    abstract val stations: ConfigurableFileCollection

    @get:Nested
    abstract val limits: Limits

    @get:Input
    @get:Optional
    @get:Option(option = "require", description = "A station id that must be present.")
    abstract val required: SetProperty<String>

    @get:Input
    @get:Option(option = "fail-on-error", description = "Fails the build when a problem is found.")
    abstract val failOnError: Property<Boolean>

    @get:Input
    @get:Option(option = "report", description = "How much the report file lists.")
    abstract val report: Property<ReportMode>

    @get:Internal
    abstract val maxMessages: Property<Int>

    @get:OutputFile
    abstract val reportFile: RegularFileProperty

    init {
        failOnError.convention(true)
        report.convention(ReportMode.SUMMARY)
        maxMessages.convention(10)
        limits.minConstituents.convention(1)
        limits.maxConstituents.convention(8)
    }

    fun limits(action: Action<in Limits>) = action.execute(limits)

    @TaskAction
    fun check() {
        val min = limits.minConstituents.get()
        val max = limits.maxConstituents.get()
        val seen = sortedSetOf<String>()
        val messages = mutableListOf<String>()

        for (file in stations.files.sortedBy { it.name }) {
            file.readLines().drop(1).forEachIndexed { index, line ->
                val where = "${file.name}:${index + 2}"
                val columns = line.split(",")
                if (columns.size != 5) {
                    messages += "$where expected 5 columns, found ${columns.size}"
                    return@forEachIndexed
                }
                if (!seen.add(columns[0])) messages += "$where duplicate station id ${columns[0]}"
                range(where, "latitude", columns[2], -90.0, 90.0)?.let(messages::add)
                range(where, "longitude", columns[3], -180.0, 180.0)?.let(messages::add)
                val n = if (columns[4].isEmpty()) 0 else columns[4].split(";").size
                if (n < min) messages += "$where has $n constituent(s), at least $min required"
                if (n > max) messages += "$where has $n constituent(s), at most $max allowed"
            }
        }
        required.get().filter { it !in seen }.forEach { messages += "required station $it is missing" }

        messages.take(maxMessages.get()).forEach { logger.error(it) }
        val out = reportFile.get().asFile
        out.parentFile.mkdirs()
        out.writeText(buildString {
            appendLine("stations=${seen.size}")
            appendLine("problems=${messages.size}")
            if (report.get() == ReportMode.FULL) appendLine("ids=${seen.joinToString(",")}")
        })
        if (messages.isEmpty()) {
            logger.lifecycle("${stations.files.size} file(s), ${seen.size} station(s): OK")
        } else if (failOnError.get()) {
            throw GradleException("${messages.size} problem(s) found in station data")
        } else {
            logger.warn("${messages.size} problem(s) found in station data")
        }
    }

    private fun range(where: String, what: String, text: String, min: Double, max: Double): String? {
        val value = text.toDoubleOrNull() ?: return "$where $what is not a number: '$text'"
        return if (value < min || value > max) "$where $what $value is outside [$min, $max]" else null
    }
}
buildSrc/build.gradle.kts
plugins {
    `kotlin-dsl`
}

repositories {
    mavenCentral()
}

For the Groovy DSL project the class is in three files under buildSrc/src/main/groovy/io/tidepool/build/, with this buildSrc/build.gradle:

buildSrc/build.gradle (Groovy DSL project)
plugins {
    id 'groovy'
}
buildSrc/src/main/groovy/io/tidepool/build/ReportMode.groovy
package io.tidepool.build

enum ReportMode { SUMMARY, FULL }
buildSrc/src/main/groovy/io/tidepool/build/Limits.groovy
package io.tidepool.build

import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input

interface Limits {
    @Input
    Property<Integer> getMinConstituents()

    @Input
    Property<Integer> getMaxConstituents()
}
buildSrc/src/main/groovy/io/tidepool/build/CheckStations.groovy
package io.tidepool.build

import org.gradle.api.Action
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.provider.SetProperty
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.Nested
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.SkipWhenEmpty
import org.gradle.api.tasks.TaskAction
import org.gradle.api.tasks.options.Option

@CacheableTask
abstract class CheckStations extends DefaultTask {

    @InputFiles
    @SkipWhenEmpty
    @PathSensitive(PathSensitivity.RELATIVE)
    abstract ConfigurableFileCollection getStations()

    @Nested
    abstract Limits getLimits()

    @Input
    @Optional
    @Option(option = 'require', description = 'A station id that must be present.')
    abstract SetProperty<String> getRequired()

    @Input
    @Option(option = 'fail-on-error', description = 'Fails the build when a problem is found.')
    abstract Property<Boolean> getFailOnError()

    @Input
    @Option(option = 'report', description = 'How much the report file lists.')
    abstract Property<ReportMode> getReport()

    @Internal
    abstract Property<Integer> getMaxMessages()

    @OutputFile
    abstract RegularFileProperty getReportFile()

    CheckStations() {
        failOnError.convention(true)
        report.convention(ReportMode.SUMMARY)
        maxMessages.convention(10)
        limits.minConstituents.convention(1)
        limits.maxConstituents.convention(8)
    }

    void limits(Action<? super Limits> action) {
        action.execute(limits)
    }

    @TaskAction
    void check() {
        int min = limits.minConstituents.get()
        int max = limits.maxConstituents.get()
        def seen = new TreeSet<String>()
        def messages = []

        for (File file : stations.files.sort { it.name }) {
            List<String> lines = file.readLines()
            for (int i = 1; i < lines.size(); i++) {
                def where = "${file.name}:${i + 1}"
                def columns = lines[i].split(',', -1)
                if (columns.length != 5) {
                    messages << "$where expected 5 columns, found ${columns.length}".toString()
                    continue
                }
                if (!seen.add(columns[0])) messages << "$where duplicate station id ${columns[0]}".toString()
                [range(where, 'latitude', columns[2], -90d, 90d),
                 range(where, 'longitude', columns[3], -180d, 180d)].findAll { it }.each { messages << it }
                int n = columns[4].isEmpty() ? 0 : columns[4].split(';').length
                if (n < min) messages << "$where has $n constituent(s), at least $min required".toString()
                if (n > max) messages << "$where has $n constituent(s), at most $max allowed".toString()
            }
        }
        required.get().findAll { !(it in seen) }.each { messages << "required station $it is missing".toString() }

        messages.take(maxMessages.get()).each { logger.error(it) }
        def out = reportFile.get().asFile
        out.parentFile.mkdirs()
        def text = new StringBuilder()
        text << "stations=${seen.size()}\n" << "problems=${messages.size()}\n"
        if (report.get() == ReportMode.FULL) text << "ids=${seen.join(',')}\n"
        out.text = text.toString()
        if (messages.isEmpty()) {
            logger.lifecycle("${stations.files.size()} file(s), ${seen.size()} station(s): OK")
        } else if (failOnError.get()) {
            throw new GradleException("${messages.size()} problem(s) found in station data")
        } else {
            logger.warn("${messages.size()} problem(s) found in station data")
        }
    }

    private static String range(String where, String what, String text, double min, double max) {
        Double value = text.isDouble() ? text.toDouble() : null
        if (value == null) return "$where $what is not a number: '$text'".toString()
        return (value < min || value > max) ? "$where $what $value is outside [$min, $max]".toString() : null
    }
}

Points worth noting in the class:

  • stations combines @InputFiles, @SkipWhenEmpty and @PathSensitive(PathSensitivity.RELATIVE): the files' content is an input, a build with no station files skips the task, and only the path relative to the build counts, so the result can be reused from another directory (see Up to date, NO-SOURCE and the build cache).

  • limits is declared @Nested on an abstract getter of the interface type Limits. Gradle creates the instance. The extra method limits(Action<in Limits>) is what lets a build script write limits { minConstituents = 2 }. It is needed in both DSLs: with the method removed, the Kotlin DSL build fails to compile the script and the Groovy DSL build fails with No signature of method: io.tidepool.build.CheckStations.limits() is applicable.

  • required is @Optional: it has no convention, so it can be empty.

  • Conventions (failOnError.convention(true) and friends) are set in the constructor (init in Kotlin), so a task has usable defaults before the build script configures it, and the build script can still replace them. convention is a default that set overrides; it is explained on Lazy Configuration & Providers.

  • The action calls get() on every property. That is correct here: it runs in the execution phase, when all values are final. Calling get() in the configuration phase is the classic bug of the lazy API.

  • The Groovy class avoids a closure around the private helper range. A first version of the Groovy class called it from inside each { } and failed with argument type mismatch (see Classic mistakes).

The build scripts register four instances of the one class: the real file with a required id and a stricter limit, a deliberately broken file that fails the build, the same broken file with failOnError off, and an empty one. The broken file is

id,name,latitude,longitude,constituents
SF,San Francisco,37.8063,-122.4659,M2:0.55:120;S2:0.13:150
SF,San Francisco again,37.8063,-122.4659,M2:0.55:120
XX,Nowhere,95.0,-122.4,M2:1.0:0
HNL,Honolulu,21.3067,not-a-number,
build.gradle.kts
import io.tidepool.build.CheckStations

val checkStations = tasks.register<CheckStations>("checkStations") {
    stations.from("data/stations.csv")
    required.add("SF")
    limits {
        minConstituents = 2
    }
    reportFile = layout.buildDirectory.file("reports/stations.txt")
}

tasks.register<CheckStations>("checkBrokenStations") {
    stations.from("data/stations-broken.csv")
    reportFile = layout.buildDirectory.file("reports/stations-broken.txt")
}

tasks.register<CheckStations>("checkBrokenLenient") {
    stations.from("data/stations-broken.csv")
    failOnError = false
    reportFile = layout.buildDirectory.file("reports/stations-lenient.txt")
}

tasks.register("printReport") {
    val report = checkStations.flatMap { it.reportFile }
    inputs.file(report)
    doLast {
        println(report.get().asFile.readText())
    }
}

tasks.register<CheckStations>("checkNothing") {
    reportFile = layout.buildDirectory.file("reports/nothing.txt")
}
build.gradle
import io.tidepool.build.CheckStations

def checkStations = tasks.register('checkStations', CheckStations) {
    stations.from('data/stations.csv')
    required.add('SF')
    limits {
        minConstituents = 2
    }
    reportFile = layout.buildDirectory.file('reports/stations.txt')
}

tasks.register('checkBrokenStations', CheckStations) {
    stations.from('data/stations-broken.csv')
    reportFile = layout.buildDirectory.file('reports/stations-broken.txt')
}

tasks.register('checkBrokenLenient', CheckStations) {
    stations.from('data/stations-broken.csv')
    failOnError = false
    reportFile = layout.buildDirectory.file('reports/stations-lenient.txt')
}

tasks.register('printReport') {
    def report = checkStations.flatMap { it.reportFile }
    inputs.file(report)
    doLast {
        println(report.get().asFile.text)
    }
}

tasks.register('checkNothing', CheckStations) {
    reportFile = layout.buildDirectory.file('reports/nothing.txt')
}

Running the task

The first run executes the task, the second finds nothing changed. The buildSrc tasks that compile the class are the first thing that runs after the class is edited; later runs find them up to date.

$ ./gradlew checkStations --build-cache --configuration-cache
Calculating task graph as no cached configuration is available for tasks: checkStations
(buildSrc tasks omitted)
> Task :checkStations
1 file(s), 3 station(s): OK
BUILD SUCCESSFUL in 883ms
4 actionable tasks: 1 executed, 3 up-to-date
Configuration cache entry stored.

$ ./gradlew checkStations --build-cache --configuration-cache
Reusing configuration cache.
> Task :checkStations UP-TO-DATE
BUILD SUCCESSFUL in 684ms
1 actionable task: 1 up-to-date
Configuration cache entry reused.

Both runs used --build-cache --configuration-cache. The class and the builds are compatible with the configuration cache: the task action reads only the task’s own properties and the logger.

A failing check names the file and the row, then fails the task with one summary line; with failOnError off the same problems are logged at the warn level and the build succeeds:

$ ./gradlew checkBrokenStations
(buildSrc tasks omitted)
> Task :checkBrokenStations FAILED
stations-broken.csv:3 duplicate station id SF
stations-broken.csv:4 latitude 95.0 is outside [-90.0, 90.0]
stations-broken.csv:5 longitude is not a number: 'not-a-number'
stations-broken.csv:5 has 0 constituent(s), at least 1 required
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':checkBrokenStations' (registered in build file 'build.gradle.kts').
> 4 problem(s) found in station data
BUILD FAILED in 918ms
4 actionable tasks: 1 executed, 3 up-to-date

$ ./gradlew checkBrokenLenient
(buildSrc tasks omitted)
> Task :checkBrokenLenient
stations-broken.csv:3 duplicate station id SF
stations-broken.csv:4 latitude 95.0 is outside [-90.0, 90.0]
stations-broken.csv:5 longitude is not a number: 'not-a-number'
stations-broken.csv:5 has 0 constituent(s), at least 1 required
4 problem(s) found in station data
BUILD SUCCESSFUL in 1s
4 actionable tasks: 1 executed, 3 up-to-date

The Groovy DSL project prints the same lines (the text registered in build file 'build.gradle' replaces 'build.gradle.kts').

Wiring a task’s output into another task

When one task’s output property is used as another task’s input, Gradle infers the dependency and runs them in the right order, with no dependsOn. The printReport task of the example takes checkStations.flatMap { it.reportFile } as its input file, so running it runs checkStations first when the report is stale or missing:

$ ./gradlew printReport
(buildSrc tasks omitted)
> Task :checkStations UP-TO-DATE
> Task :printReport
stations=3
problems=0
BUILD SUCCESSFUL in 1s
5 actionable tasks: 1 executed, 4 up-to-date

$ ./gradlew printReport
(buildSrc tasks omitted)
> Task :checkStations
1 file(s), 3 station(s): OK
> Task :printReport
stations=3
problems=0
BUILD SUCCESSFUL in 1s
5 actionable tasks: 2 executed, 3 up-to-date

The first run found checkStations up to date; the second ran after rm -rf build, which removed the report, so checkStations executed. flatMap (rather than map) is used because reportFile is itself a property: the result is a provider that carries the producing task along. Passing reportFile.get() instead would lose the link and break the ordering. The same rule explains why inputs.file(report) is declared on a task with no class: it declares the input. The page on tasks explains why wired inputs are preferred over dependsOn (see Gradle Tasks).

Command-line options

A property of a task class can be set from the command line when it is annotated with @Option. The annotation goes on the getter of a Property (@get:Option in Kotlin) or on the setter of a plain property, takes an option name without the dashes and a description, and the option is then valid after the task name. The supported types are:

Property type Command-line form

boolean, Boolean, Property<Boolean>

--flag; Gradle also creates the opposite --no-flag. Absent: the property keeps its default.

Integer, Long, Double (and Property<...> of them)

--name=value or --name value

String, Property<String>

--name=value or --name value

enum, Property<enum>

--name=value, not case-sensitive

List<T>, ListProperty<T>, SetProperty<T> with T a number, String or enum

The option repeated: --name=a --name=b. Commas and spaces are not separators.

DirectoryProperty, RegularFileProperty

A path; a relative path is resolved against the project directory.

@OptionValues("name") on a method that returns a List documents the accepted values of a String option; Gradle does not enforce them, the task action must. Options are self-documenting through help --task:

$ ./gradlew -q help --task checkStations
Detailed task information for checkStations
Path
     :checkStations (registered in build file 'build.gradle.kts')
Type
     CheckStations (io.tidepool.build.CheckStations)
Options
     --fail-on-error     Fails the build when a problem is found.
     --no-fail-on-error     Disables option --fail-on-error.
     --report     How much the report file lists.
                  Available values are:
                       FULL
                       SUMMARY
     --require     A station id that must be present.
     --rerun     Causes the task to be re-run even if up-to-date.
Description
     -
Group
     -

The output also shows the built-in --rerun option that every task has. Using the repeated option and the negated boolean option on the real data:

$ ./gradlew checkStations --require=SF --require=XX --report=full --no-fail-on-error
(buildSrc tasks omitted)
> Task :checkStations
required station XX is missing
1 problem(s) found in station data
BUILD SUCCESSFUL in 1s
4 actionable tasks: 1 executed, 3 up-to-date

$ cat build/reports/stations.txt
stations=3
problems=1
ids=BOS,HNL,SF

--require=SF --require=XX asks for two ids and XX is not in the file; --no-fail-on-error kept the build green, and --report=full made the report list the ids. An invalid enum value is rejected before the task runs:

$ ./gradlew checkStations --report=nope
(buildSrc tasks omitted)
FAILURE: Build failed with an exception.
* What went wrong:
Problem configuring option 'report' on task ':checkStations' from command line.
> Cannot convert string value 'nope' to an enum value of type 'io.tidepool.build.ReportMode' (valid case insensitive values: SUMMARY, FULL)
BUILD FAILED in 973ms
3 actionable tasks: 3 up-to-date

Options can only be declared with the annotation: there is no programmatic equivalent, and they cannot be declared on a project or in a plugin. The task that exposes the option must be named explicitly (gradle check --tests abc does not reach the test task that check depends on).

Up to date, NO-SOURCE and the build cache

The same annotations drive the outcome that Gradle prints next to the task name:

  • UP-TO-DATE: inputs and outputs are the same as in the last execution of this task in this directory.

  • FROM-CACHE: the build cache (--build-cache, or org.gradle.caching=true) holds an entry for these inputs, and the outputs were restored from it. It requires @CacheableTask.

  • NO-SOURCE: an @SkipWhenEmpty input is empty (the task checkNothing has no station files).

  • EXECUTED (no suffix): the action ran.

$ ./gradlew checkStations --build-cache --configuration-cache
Reusing configuration cache.
> Task :checkStations FROM-CACHE
BUILD SUCCESSFUL in 692ms
1 actionable task: 1 from cache
Configuration cache entry reused.

$ ./gradlew checkNothing
(buildSrc tasks omitted)
> Task :checkNothing NO-SOURCE
BUILD SUCCESSFUL in 1s
3 actionable tasks: 3 up-to-date

The first listing is a run after rm -r build/reports: the report was gone, but the build cache had it from the first run, so Gradle restored it. The task needs --build-cache for that (the build also sets the local cache directory to build-cache in settings.gradle.kts, to keep the example self-contained: buildCache { local { directory = file("build-cache") } }; the Groovy DSL project uses file('build-cache')). To see why a task is out of date, run with --info; this excerpt is the run after the first station’s latitude was edited (37.8063 to 37.8064), after which the file was restored and the next run was FROM-CACHE again:

$ ./gradlew checkStations --build-cache --configuration-cache --info   (excerpt)
Task ':checkStations' is not up-to-date because:
  Input property 'stations' file /work/tidepool/data/stations.csv has changed.

$ ./gradlew checkStations --build-cache --configuration-cache
Reusing configuration cache.
> Task :checkStations FROM-CACHE
BUILD SUCCESSFUL in 764ms
1 actionable task: 1 from cache
Configuration cache entry reused.

Relocatability. An input file is identified by its absolute path unless @PathSensitive says otherwise, so a cacheable task whose inputs use the default would never hit the cache from another directory or another machine. checkStations uses PathSensitivity.RELATIVE. To check that, the project (including its build-cache directory but not build) was copied to another directory and run there:

$ ./gradlew checkStations --build-cache
(buildSrc tasks omitted)
> Task :checkStations FROM-CACHE
BUILD SUCCESSFUL in 1s
4 actionable tasks: 1 from cache, 3 up-to-date

The result was restored from the cache although the project had a different location; the buildSrc tasks ran because that copy had never built them. A task is only worth marking @CacheableTask when its outputs are reproducible and relocatable, and when the work is more than moving files (which is why Copy and Jar are not cacheable); the page on the build cache (later in this section) covers cache keys and remote caches.

Where to put task classes

Location Visible to Notes

The build script itself

Only that script.

Fine for a prototype, as in the first example. A class declared in a build script is compiled with it and cannot be reused by another project or unit-tested.

buildSrc

The build scripts of every project in the build.

Built automatically before the rest of the build, added to the build script classpath, and no settings.gradle.kts change is needed. A change in buildSrc rebuilds it and invalidates the configuration phase (and the configuration cache); project tasks whose inputs did not change can still be UP-TO-DATE, as a neutral buildSrc edit showed when tried here.

An included build, conventionally build-logic

The projects that apply a plugin from it.

Declared with pluginManagement { includeBuild("build-logic") } in the settings file. The Gradle manual recommends it over buildSrc, because a change in an included build invalidates less. The classes are reached through a plugin.

A binary plugin published or reused across builds

Any build that applies the plugin.

The task class is part of the plugin JAR; see Binary Plugins.

The buildSrc version was shown above; its build scripts import the class (import io.tidepool.build.CheckStations) and use it directly. In an included build-logic build the class is not on the build script classpath by itself; the usual pattern is a precompiled script plugin that registers the task, applied by id:

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

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

repositories {
    mavenCentral()
}
Kotlin DSL: build-logic/src/main/kotlin/tidepool.stations.gradle.kts
import io.tidepool.build.CheckStations

tasks.register<CheckStations>("checkStations") {
    stations.from("data/stations.csv")
    reportFile = layout.buildDirectory.file("reports/stations.txt")
}
Kotlin DSL: build.gradle.kts
plugins {
    id("tidepool.stations")
}

The task class CheckStations.kt is the same file as above, under build-logic/src/main/kotlin/io/tidepool/build/.

Groovy DSL: settings.gradle
pluginManagement {
    includeBuild 'build-logic'
}

rootProject.name = 'tidepool'
Groovy DSL: build-logic/settings.gradle
rootProject.name = 'build-logic'
Groovy DSL: build-logic/build.gradle
plugins {
    id 'groovy-gradle-plugin'
}
Groovy DSL: build-logic/src/main/groovy/tidepool.stations.gradle
import io.tidepool.build.CheckStations

tasks.register('checkStations', CheckStations) {
    stations.from('data/stations.csv')
    reportFile = layout.buildDirectory.file('reports/stations.txt')
}
Groovy DSL: build.gradle
plugins {
    id 'tidepool.stations'
}

The three Groovy class files are the ones shown above, under build-logic/src/main/groovy/io/tidepool/build/. The file name tidepool.stations.gradle(.kts) becomes the plugin id tidepool.stations. Running it for the first time compiles build-logic; the second run is up to date:

$ ./gradlew checkStations --configuration-cache
Calculating task graph as no cached configuration is available for tasks: checkStations
> Task :build-logic:checkKotlinGradlePluginConfigurationErrors SKIPPED
> Task :build-logic:generateExternalPluginSpecBuilders
> Task :build-logic:extractPrecompiledScriptPluginPlugins
> Task :build-logic:generateScriptPluginAdapters
> Task :build-logic:pluginDescriptors
> Task :build-logic:processResources
> Task :build-logic:compilePluginsBlocks
> Task :build-logic:generatePrecompiledScriptPluginAccessors
> Task :build-logic:compileKotlin
> Task :build-logic:compileJava NO-SOURCE
> Task :build-logic:classes
> Task :build-logic:jar
> Task :checkStations
1 file(s), 3 station(s): OK
BUILD SUCCESSFUL in 5s
10 actionable tasks: 10 executed
Configuration cache entry stored.

$ ./gradlew checkStations --configuration-cache
Reusing configuration cache.
> Task :checkStations UP-TO-DATE
BUILD SUCCESSFUL in 729ms
1 actionable task: 1 up-to-date
Configuration cache entry reused.

(Kotlin DSL; run with --configuration-cache, and compatible with it.) The Groovy DSL project ran the same two builds with the tasks of the groovy-gradle-plugin (:build-logic:compileGroovyPlugins, :build-logic:extractPluginRequests and so on) and the same checkStations lines. Convention plugins, precompiled script plugins and how they replace allprojects/subprojects blocks are the subject of Convention Plugins & Sharing Build Logic.

Custom task classes are also what binary plugins register for their users; the plugin side (extensions, plugin ids, the gradlePlugin {} block, validatePlugins) is on Binary Plugins and its test side on Testing & Publishing Plugins.

Services and work that should not block the task

A task needing a Gradle service (file operations, archive operations, the worker executor, ExecOperations) declares an abstract getter annotated with @Inject; Gradle supplies the implementation, which is the supported replacement for the project.copy, project.exec and similar methods at execution time (not run here; the mechanism is described in Lazy Configuration & Providers). A task that splits its work into units that can run in parallel, or in an isolated classloader or process, hands them to the Worker API; that and shared build services are on Worker API & Build Services.

Classic mistakes

All of the following were run; the Kotlin DSL listings are shown (the Groovy DSL builds fail the same way, with the type shown as Unannotated instead of Build_gradle.Unannotated).

A property with no annotation. The failure comes before any task runs:

abstract class Unannotated : DefaultTask() {
    abstract val label: Property<String>

    @TaskAction
    fun run() {
        println("label = ${label.get()}")
    }
}
$ ./gradlew unannotated
FAILURE: Build failed with an exception.
* What went wrong:
A problem was found with the configuration of task ':unannotated' (type 'Build_gradle.Unannotated').
Missing annotation
  Type 'Build_gradle.Unannotated' property 'label' is missing an input or output annotation
    Properties must be annotated so that Gradle knows how to handle them during up-to-date checking
    For more information, please refer to https://docs.gradle.org/9.8.1/userguide/validation_problems.html#missing_annotation in the Gradle documentation.
    Possible solutions:
      1. Add an input or output annotation.
      2. Mark it as @Internal.
BUILD FAILED in 746ms

An input that nobody sets. An @Input without @Optional and without a value fails when the task is about to run:

$ ./gradlew unset
> Task :unset FAILED
FAILURE: Build failed with an exception.
* What went wrong:
A problem was found with the configuration of task ':unset' (type 'Build_gradle.Unset').
Value not set
  Type 'Build_gradle.Unset' property 'label' 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 'label'.
      2. Mark property 'label' as optional.
BUILD FAILED in 788ms
1 actionable task: 1 executed

Using project in the task action. It works with the cache off, with a deprecation warning that Gradle 10 turns into an error, and it fails with the configuration cache on, because the cache stores tasks and cannot store the Project:

abstract class UsesProject : DefaultTask() {
    @TaskAction
    fun run() {
        println("project = ${project.name}")
    }
}
$ ./gradlew usesProject --warning-mode all
> Task :usesProject
Invocation of Task.project at execution time has been deprecated. This will fail with an error in Gradle 10. [...]
    at Build_gradle$UsesProject.run(build.gradle.kts:31)
    (Run with --stacktrace to get the full stack trace of this deprecation warning.)
project = bugs
BUILD SUCCESSFUL in 792ms
1 actionable task: 1 executed

$ ./gradlew usesProject --configuration-cache
Calculating task graph as no cached configuration is available for tasks: usesProject
> Task :usesProject FAILED
1 problem was found storing the configuration cache.
- Task `:usesProject` of type `Build_gradle$UsesProject`: invocation of 'Task.project' at execution time is unsupported with the configuration cache.
  See https://docs.gradle.org/9.8.1/userguide/configuration_cache_requirements.html#config_cache:requirements:use_project_during_execution
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':usesProject' (registered in build file 'build.gradle.kts').
> Invocation of 'Task.project' by task ':usesProject' at execution time is unsupported with the configuration cache.
BUILD FAILED in 842ms
1 actionable task: 1 executed
Configuration cache entry discarded with 1 problem.

(The long deprecation line is cut at [...].) The fix is to copy what the action needs into a property during configuration (@get:Input abstract val projectName: Property<String>, set with projectName = project.name in the register block) or to inject a service. The deprecation was introduced in Gradle 7.4 behind a feature flag; since 8.12 the flag no longer controls it, and it is issued whenever the configuration cache is off. With the cache on, the same call is reported as a configuration cache problem instead, as the second run above shows (see Upgrading within Gradle 8.x: Calling Task.getProject() from a task action).

Symptom Cause Fix

property 'x' is missing an input or output annotation

A getter with no input, output or @Internal annotation.

Annotate it, or mark it @Internal. See Validation problems: missing annotation.

property 'x' doesn't have a configured value

An @Input or @Output... property with no value and no @Optional.

Set it in the build script, give it a convention, or add @Optional. See Validation problems: value not set.

A Kotlin annotation that has no effect

The annotation was written without get:.

@get:Input.

No signature of method ... limits() is applicable (Groovy) or a script compilation error (Kotlin)

A @Nested property has no limits(Action) method for the limits { } block.

Add void limits(Action<? super Limits> action).

The task always runs

No outputs declared, or an @UntrackedTask.

Declare an @OutputFile or @OutputDirectory property.

Cache hit on one machine only

File inputs with the default absolute path sensitivity.

@PathSensitive(PathSensitivity.RELATIVE) (or NAME_ONLY).

argument type mismatch (Groovy)

A closure inside the task action called a private method of the task class; Gradle’s generated subclass cannot dispatch it.

Call the helper from a plain loop, or make it static and public/protected.

The consuming task runs before the producing one

The producer’s output was passed to the consumer as a plain value (get()), so no dependency was inferred.

Wire the Provider (map, flatMap), so the dependency travels with it.

The Groovy argument type mismatch row is an observation from a first version of the Groovy class (a closure inside check() calling the private range method); the cause given is inferred from the stack trace, where the call goes through Gradle’s call-intercepting meta class on the decorated subclass, and is not taken from the manual.

What changed from Gradle 8.14 and from the Gradle 1.x book

Nothing on this page was run on Gradle 8.14.6; the notes below come from the official upgrade guides and are labelled "not run here".

Item Status

Task.project in a task action

Deprecated since 7.4 behind a flag, issued without the flag since 8.12 when the configuration cache is off, an error in Gradle 10; with the cache on, a configuration cache problem instead. Not run here on 8.14.

Subclasses of Gradle classes with @Inject getters

Must be abstract since 9.0. Not run here on 8.14.

= assignment for Property in the Kotlin DSL

Since 8.2. On earlier versions use set(...).

Configuration cache

Stable since 8.1, the preferred mode in 9.0, announced as the default for Gradle 10. Every example above whose command shows --configuration-cache ran with it.

task name << { ... } (Gradle 1.x)

Removed in 5.0; use tasks.register and doLast.

task name(type: X) { ... } and eager task creation

Eager creation still works but is discouraged; tasks.register avoids creating the task unless it is needed.

Task classes with getX()/setX() and @Input File x fields

Still work, but without the lazy Property types they cannot be wired to other tasks' outputs with inferred dependencies.

The same idea in the other two tools

Concept Gradle Maven Ant

Unit of custom work

A task class (DefaultTask) and @TaskAction.

A Mojo (@Mojo, execute()) in a plugin.

A Java class that extends Task, with execute().

Inputs

Annotated properties, tracked for up-to-date checks and the cache.

@Parameter fields, configured in the POM; no tracking.

Attribute setters and nested elements; no tracking.

Skip when unchanged

Built in (UP-TO-DATE, FROM-CACHE).

Each plugin decides; Maven does not track task inputs itself.

Each task decides; the uptodate task and the tasks' own timestamp checks are the tools.

Where it lives

Build script, buildSrc, included build, plugin.

A plugin, always.

A JAR registered with taskdef or an antlib.