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). Every example was run through the wrapper in a scratch copy of the tidepool project, once with Kotlin DSL build files and once with Groovy DSL build files, with --console=plain and --configuration-cache, and every working example on this page is compatible with the configuration cache (none reported a configuration cache problem). The listings leave out blank lines, the "Calculating task graph" and "Configuration cache entry" lines and the closing configuration cache hint to keep the focus on tasks; the two removed-syntax examples near the end of the registering section are the only builds run without the cache, because they fail before the cache matters. The scratch copy pointed its 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

Features newer than Gradle 8.14 name their release and say whether they are incubating, deprecated or removed. Gradle 8.14.x was not installed here, so statements about it come from the official documentation and are labelled "not run here".

A Gradle build is a graph of tasks, and everything else (plugins, dependency resolution, the build cache, the configuration cache) exists to decide which tasks run, in which order, and whether they can be skipped. A task is the unit of work that Gradle schedules, orders, skips and caches. In Ant the unit is the target (see Ant Targets & Extension Points); in Maven the unit is a plugin goal bound to a lifecycle phase (see Maven Build Lifecycle). The shared comparison is in Build Automation Fundamentals. This page is about using tasks that plugins and scripts already define: listing them, registering simple ones, wiring and ordering them, skipping them and reading their outcomes. Writing a task class with typed inputs and outputs is in Writing Custom Tasks, how Gradle decides that a task is up to date is in Incremental Build, and the Property and Provider types that connect tasks lazily are in Lazy Configuration & Providers. For the vocabulary (build, project, task graph) see Gradle Core Concepts, and for when task code runs see Build Lifecycle.

What a task is

A task is an object of type org.gradle.api.Task that belongs to a project. It has

  • a name and a path: the name is unique inside the project, the path adds the project (:tidepool-model:jar is the task jar of the project :tidepool-model; :stageData is a task of the root project);

  • a type: plain Task (a task defined by a script), or a class such as Copy, Zip, Jar, JavaCompile or Test that carries behaviour and typed properties;

  • actions: Kotlin or Groovy blocks run in order when the task executes (doFirst adds one at the front, doLast at the end), plus the single @TaskAction method of a task class;

  • inputs and outputs, which Gradle uses to decide whether the task is up to date and whether its result can be taken from the build cache;

  • dependencies on other tasks, ordering rules and finalizers;

  • a group and a description, which only matter for display.

Gradle builds the set of tasks to run from the names on the command line. A name such as build selects the task of that name in the current project and in every subproject; a path such as :tidepool-model:build selects one task. The camel-case abbreviation sR for stationReport, -x to exclude a task and the other command-line rules are on Command-Line Interface. The shared page that sets the three tools side by side is Comparing Ant, Maven & Gradle.

Listing tasks, groups and descriptions

Two tasks of the help family answer "what can I run here?": tasks lists the tasks of a project that have a group (the groups are the headings of the listing, and a task without a group is a hidden task), and help --task <name> describes one task. The root project of this example has two tasks in the group tidepool, a Copy task that stages the station data and a plain task that reports how many rows were staged, and one task with no group:

Kotlin DSL
val stageData = tasks.register<Copy>("stageData") {
    group = "tidepool"
    description = "Copies the station data into build/staging."
    from("data")
    into(layout.buildDirectory.dir("staging"))
}

tasks.register("stationReport") {
    group = "tidepool"
    description = "Prints the number of staged station rows."
    dependsOn(stageData)
    val staged = layout.buildDirectory.file("staging/stations.csv")
    doLast {
        val rows = staged.get().asFile.readLines().size - 1
        println("$rows stations staged")
    }
}

tasks.register("cleanupHelper") {
    doLast { println("not listed: no group") }
}
Groovy DSL
def stageData = tasks.register('stageData', Copy) {
    group = 'tidepool'
    description = 'Copies the station data into build/staging.'
    from 'data'
    into layout.buildDirectory.dir('staging')
}

tasks.register('stationReport') {
    group = 'tidepool'
    description = 'Prints the number of staged station rows.'
    dependsOn stageData
    def staged = layout.buildDirectory.file('staging/stations.csv')
    doLast {
        def rows = staged.get().asFile.readLines().size() - 1
        println "${rows} stations staged"
    }
}

tasks.register('cleanupHelper') {
    doLast { println 'not listed: no group' }
}

The group and description properties exist only to organise the tasks listing, so a task that people should run gets both and a helper task that only other tasks use does not. The same output comes from both DSLs:

./gradlew --console=plain tasks --group=tidepool
> Task :tasks
------------------------------------------------------------
Tasks runnable from root project 'tidepool'
------------------------------------------------------------
Tidepool tasks
--------------
stageData - Copies the station data into build/staging.
stationReport - Prints the number of staged station rows.
To see all tasks and more detail, run gradlew tasks --all
To see more detail about a task, run gradlew help --task <task>
BUILD SUCCESSFUL in 1s
1 actionable task: 1 executed

The task cleanupHelper has no group, so it is not in that list, and it is not among the standard groups either. It is listed under "Other tasks" when tasks --all is used, which also shows every task without a group and the tasks of subprojects (the middle part of the output, the standard Build Setup and Help groups, is left out):

./gradlew --console=plain tasks --all
> Task :tasks
------------------------------------------------------------
Tasks runnable from root project 'tidepool'
------------------------------------------------------------
...
Tidepool tasks
--------------
stageData - Copies the station data into build/staging.
stationReport - Prints the number of staged station rows.
Other tasks
-----------
cleanupHelper
prepareKotlinBuildScriptModel
BUILD SUCCESSFUL in 717ms
1 actionable task: 1 executed

prepareKotlinBuildScriptModel is an internal task that the IDE integration uses; it is listed in both DSLs, and kotlinDslAccessorsReport, a Help task of the Kotlin DSL support, was listed only in the Kotlin DSL run. A hidden task is still a normal task; it just is not advertised:

./gradlew --console=plain cleanupHelper
> Task :cleanupHelper
not listed: no group
BUILD SUCCESSFUL in 774ms
1 actionable task: 1 executed

help --task prints everything Gradle knows about a task: its path with the place where it was registered (see Task provenance), its type, the command-line options that its class declares (every task has --rerun, which runs it even if it is up to date), the description and the group. The Groovy DSL run printed the same text with build.gradle in the path line:

./gradlew --console=plain help --task stationReport
> Task :help
Detailed task information for stationReport
Path
     :stationReport (registered in build file 'build.gradle.kts')
Type
     Task (org.gradle.api.Task)
Options
     --rerun     Causes the task to be re-run even if up-to-date.
Description
     Prints the number of staged station rows.
Group
     tidepool
BUILD SUCCESSFUL in 730ms
1 actionable task: 1 executed

Names can be abbreviated to the letters of their camel-case humps as long as the result is unambiguous. The abbreviation sR selected stationReport; the command line page covers the rule and its errors:

./gradlew --console=plain sR
> Task :stageData UP-TO-DATE
> Task :stationReport
3 stations staged
BUILD SUCCESSFUL in 877ms
2 actionable tasks: 1 executed, 1 up-to-date

Because stationReport calls dependsOn(stageData), stageData ran first (it was up to date here because an earlier command had already staged the files). Task groups and descriptions are the documentation of your build for the people who run it; the best practices page recommends that every task that is meant to be run has both.

The task graph of a build

A lifecycle task has no actions of its own; it exists to give a name to a set of other tasks. The tasks of the Java plugins that every JVM build runs are a good example: build is a lifecycle task that depends on assemble (everything that produces outputs) and check (everything that verifies them), and each of those fans out to the tasks that do the work. This is the graph that ./gradlew :tidepool-model:build runs, with the dependencies that Gradle itself reported for each task (read from the task graph in a throw-away copy of the build):

flowchart TD build["build (lifecycle)"] --> assemble["assemble (lifecycle)"] build --> check["check (lifecycle)"] assemble --> jar jar --> classes["classes (lifecycle)"] classes --> compileJava classes --> processResources check --> test test --> testClasses["testClasses (lifecycle)"] test --> classes testClasses --> compileTestJava testClasses --> processTestResources compileTestJava --> classes

Gradle runs the tasks in an order that respects every arrow, running a task at most once. --dry-run (-m) prints that order without executing anything; it is the quickest way to answer "what will this command do?". For the application module, which depends on the other two, the dry run lists the compile tasks of all three modules before the packaging tasks of the application (the order of tasks that do not depend on each other is not specified and may differ between runs):

./gradlew --console=plain :tidepool-cli:build --dry-run
:tidepool-model:compileJava SKIPPED
:tidepool-engine:compileJava SKIPPED
:tidepool-cli:compileJava SKIPPED
:tidepool-cli:processResources SKIPPED
:tidepool-cli:classes SKIPPED
:tidepool-cli:jar SKIPPED
:tidepool-engine:processResources SKIPPED
:tidepool-engine:classes SKIPPED
:tidepool-engine:jar SKIPPED
:tidepool-model:processResources SKIPPED
:tidepool-model:classes SKIPPED
:tidepool-model:jar SKIPPED
:tidepool-cli:startScripts SKIPPED
:tidepool-cli:distTar SKIPPED
:tidepool-cli:distZip SKIPPED
:tidepool-cli:assemble SKIPPED
:tidepool-cli:compileTestJava SKIPPED
:tidepool-cli:processTestResources SKIPPED
:tidepool-cli:testClasses SKIPPED
:tidepool-cli:test SKIPPED
:tidepool-cli:check SKIPPED
:tidepool-cli:build SKIPPED
BUILD SUCCESSFUL in 811ms

Every task shows as SKIPPED in a dry run, because none of them executed; this is the only meaning of the label in that output. The tasks of other modules appear because the application’s compileJava needs the jars (or classes) of its project dependencies.

Task outcomes

When a task finishes, Gradle labels it in the console with one of five outcomes. The label tells you why the task did or did not do work, and reading the labels is the fastest way to see whether the incremental build and the build cache work:

Label Meaning Typical cause

none, or EXECUTED

The task ran its actions.

The task has actions and Gradle executed them; a lifecycle task shows no label when one of its dependencies executed.

UP-TO-DATE

The task’s outputs are still valid, so nothing ran.

Inputs and outputs did not change since the last run (see Incremental Build); a lifecycle task whose dependencies are all UP-TO-DATE, SKIPPED or FROM-CACHE.

FROM-CACHE

The outputs were restored from the build cache instead of running the task.

The build cache holds an entry for the same inputs, for example after clean (see Build Cache).

SKIPPED

The task did not run, although it was selected.

An onlyIf predicate returned false, or the task is enabled = false. The user guide also lists exclusion with -x as a cause, but the plain log below shows an excluded task not at all. A dry run labels every task SKIPPED, see above.

NO-SOURCE

The task has inputs and outputs but no source files, so it had nothing to do.

compileTestJava and test in a module without tests, or processResources without a resources directory.

The labels come from real runs of the tidepool-model module, which has no resources and no tests. From a clean checkout the first build executes the compile and packaging tasks and gives NO-SOURCE to the tasks that have no inputs. The first run is cold, so compileJava and jar are executed:

./gradlew --console=plain --configuration-cache :tidepool-model:build
> Task :tidepool-model:processResources NO-SOURCE
> Task :tidepool-model:processTestResources NO-SOURCE
> Task :tidepool-model:compileJava
> Task :tidepool-model:classes
> Task :tidepool-model:compileTestJava NO-SOURCE
> Task :tidepool-model:testClasses UP-TO-DATE
> Task :tidepool-model:test NO-SOURCE
> Task :tidepool-model:check UP-TO-DATE
> Task :tidepool-model:jar
> Task :tidepool-model:assemble
> Task :tidepool-model:build
BUILD SUCCESSFUL in 847ms
2 actionable tasks: 2 executed

Running the same command again changes nothing, so every task that has outputs is UP-TO-DATE; the tasks without source files are still NO-SOURCE, and check, testClasses and classes are lifecycle tasks that report UP-TO-DATE because everything they depend on is:

> Task :tidepool-model:processResources NO-SOURCE
> Task :tidepool-model:processTestResources NO-SOURCE
> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tidepool-model:classes UP-TO-DATE
> Task :tidepool-model:jar UP-TO-DATE
> Task :tidepool-model:assemble UP-TO-DATE
> Task :tidepool-model:compileTestJava NO-SOURCE
> Task :tidepool-model:testClasses UP-TO-DATE
> Task :tidepool-model:test NO-SOURCE
> Task :tidepool-model:check UP-TO-DATE
> Task :tidepool-model:build UP-TO-DATE
BUILD SUCCESSFUL in 684ms
2 actionable tasks: 2 up-to-date

With the build cache on (--build-cache), clean followed by build restores the compiled classes instead of compiling them again. compileJava shows FROM-CACHE; the jar task is executed again, because archive tasks such as Jar are not cacheable by default. The cache already held an entry for the same sources from an earlier run, which is why even the first cached run after a clean restores compileJava, and the closing summary counts the outcomes:

./gradlew --console=plain --configuration-cache --build-cache :tidepool-model:clean :tidepool-model:build
> Task :tidepool-model:clean
> Task :tidepool-model:processResources NO-SOURCE
> Task :tidepool-model:processTestResources NO-SOURCE
> Task :tidepool-model:compileJava FROM-CACHE
> Task :tidepool-model:classes UP-TO-DATE
> Task :tidepool-model:compileTestJava NO-SOURCE
> Task :tidepool-model:testClasses UP-TO-DATE
> Task :tidepool-model:test NO-SOURCE
> Task :tidepool-model:check UP-TO-DATE
> Task :tidepool-model:jar
> Task :tidepool-model:assemble
> Task :tidepool-model:build
BUILD SUCCESSFUL in 720ms
3 actionable tasks: 2 executed, 1 from cache

The Groovy DSL builds printed the same labels for the same tasks (only the order of the lines for tasks without a dependency between them differed). The summary line "3 actionable tasks: 2 executed, 1 from cache" is the sum of all labels; an actionable task is one that has actions, so lifecycle tasks are not counted.

To see why a task was not executed rather than only that it was not, run with --info. The next example is from the onlyIf section below; the two interesting lines of a long log (most of the log is daemon and cache bookkeeping, left out here with the marked ellipses) are

...
> Task :upload SKIPPED
Skipping task ':upload' as task onlyIf 'the 'offline' property is not set' is false.
...

For the cache and the incremental build see Build Cache and Incremental Build; the cache keys themselves are explained in the build cache page.

Lifecycle tasks and actionable tasks

An actionable task has actions (a copy, a compile, a script block) and usually inputs and outputs. A lifecycle task has no actions: it only collects other tasks under one name, so that ./gradlew build means "everything that makes the project buildable". The Java and base plugins define assemble, check and build for that purpose, and your own build should follow the same pattern: give people a few lifecycle tasks with clear names (dataset, qualityCheck) and put the work in actionable tasks that the lifecycle tasks depend on. dependsOn is the right tool only for this: a lifecycle task has nothing to wire to, so it can only name tasks.

In this example stageData copies the station files, packData zips what stageData produced, and dataset is the lifecycle task that people run:

Kotlin DSL
val stageData = tasks.register<Copy>("stageData") {
    from("data")
    into(layout.buildDirectory.dir("staging"))
}

// Wired input: packData consumes the outputs of stageData, so Gradle adds the
// dependency by itself and knows which files are inputs.
val packData = tasks.register<Zip>("packData") {
    from(stageData)
    archiveFileName = "stations.zip"
    destinationDirectory = layout.buildDirectory.dir("dist")
}

// Lifecycle task: no actions, only dependencies.
tasks.register("dataset") {
    group = "tidepool"
    description = "Builds the station data package."
    dependsOn(packData)
}
Groovy DSL
def stageData = tasks.register('stageData', Copy) {
    from 'data'
    into layout.buildDirectory.dir('staging')
}

// Wired input: packData consumes the outputs of stageData, so Gradle adds the
// dependency by itself and knows which files are inputs.
def packData = tasks.register('packData', Zip) {
    from stageData
    archiveFileName = 'stations.zip'
    destinationDirectory = layout.buildDirectory.dir('dist')
}

// Lifecycle task: no actions, only dependencies.
tasks.register('dataset') {
    group = 'tidepool'
    description = 'Builds the station data package.'
    dependsOn packData
}

Note what is missing: packData does not use dependsOn. Its from(stageData) passes the task provider as a source, and Gradle understands that the files to zip are the outputs of stageData, so it adds the dependency itself; this is called wiring an input to an output. The dry run shows the order, and the first real run executes all three tasks. The second run finds everything up to date, including the lifecycle task:

./gradlew --console=plain --dry-run packData
:stageData SKIPPED
:packData SKIPPED
BUILD SUCCESSFUL in 684ms
./gradlew --console=plain dataset
> Task :stageData
> Task :packData
> Task :dataset
BUILD SUCCESSFUL in 723ms
2 actionable tasks: 2 executed
> Task :stageData UP-TO-DATE
> Task :packData UP-TO-DATE
> Task :dataset UP-TO-DATE
BUILD SUCCESSFUL in 662ms
2 actionable tasks: 2 up-to-date

The Groovy DSL run printed the same lines. The "2 actionable tasks" count excludes dataset, because it has no actions.

Registering and configuring tasks

There are two generations of the API that creates tasks, and the difference decides how long the configuration phase takes in a large build.

  • The eager API (tasks.create(...), the Groovy task name { } shorthand, tasks.getByName(...)) creates the task and runs its configuration block immediately, while the build script is evaluated, for every task, whether or not the command line needs it.

  • The lazy API (tasks.register(...), tasks.named(...), configureEach) records a recipe, returns a TaskProvider and creates and configures the task only when something needs the task: the command line selected it, another task depends on it, or code called get() on the provider. This is called task configuration avoidance. It arrived as an incubating API in Gradle 4.9 (2018), and the Gradle documentation has recommended it ever since.

This build registers one task the eager way and two the lazy way, and prints a line from every configuration block, so that the output shows when each block ran:

Kotlin DSL
// Eager: the configuration block runs while the script is evaluated.
tasks.create("eagerReport") {
    println("configuring eagerReport")
    doLast { println("running eagerReport") }
}

// Lazy: the block runs only if something asks for the task.
tasks.register("lazyReport") {
    println("configuring lazyReport")
    doLast { println("running lazyReport") }
}

// Lazy configuration of every task of a type, without realizing unused tasks.
tasks.withType<Copy>().configureEach {
    println("configuring Copy task $name")
}
tasks.register<Copy>("stageData") {
    from("data")
    into(layout.buildDirectory.dir("staging"))
}
tasks.register<Copy>("stageBackup") {
    from("data")
    into(layout.buildDirectory.dir("backup"))
}

// Configure an existing task lazily, by name and type.
tasks.named<Copy>("stageBackup") {
    description = "Copies the station data to build/backup."
}
Groovy DSL
// Eager: the configuration block runs while the script is evaluated.
tasks.create('eagerReport') {
    println 'configuring eagerReport'
    doLast { println 'running eagerReport' }
}

// Lazy: the block runs only if something asks for the task.
tasks.register('lazyReport') {
    println 'configuring lazyReport'
    doLast { println 'running lazyReport' }
}

// Lazy configuration of every task of a type, without realizing unused tasks.
tasks.withType(Copy).configureEach {
    println "configuring Copy task ${name}"
}
tasks.register('stageData', Copy) {
    from 'data'
    into layout.buildDirectory.dir('staging')
}
tasks.register('stageBackup', Copy) {
    from 'data'
    into layout.buildDirectory.dir('backup')
}

// Configure an existing task lazily, by name and type.
tasks.named('stageBackup', Copy) {
    description = 'Copies the station data to build/backup.'
}

Running help needs none of these tasks, yet the eager task is configured. The Kotlin DSL also warns at compile time that create is deprecated (the path prefix in the warning is replaced by a neutral one); the Groovy DSL does not warn. Output of the Kotlin DSL run:

./gradlew --console=plain --configuration-cache help
> Configure project :
w: file:///home/dev/tidepool/build.gradle.kts:2:7: 'fun create(name: String, configuration: Action<in Task>): Task' is deprecated. Deprecated in Java.
configuring eagerReport
> Task :help
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
BUILD SUCCESSFUL in 949ms
1 actionable task: 1 executed

Only eagerReport was configured. Asking for lazyReport configures it, and nothing else but the eager task; asking for stageData runs the configureEach block for the one Copy task that exists in the graph, and not for stageBackup, which stays an unrealized recipe:

./gradlew --console=plain lazyReport
./gradlew --console=plain stageData
> Configure project :
configuring eagerReport
configuring lazyReport
> Task :lazyReport
running lazyReport
BUILD SUCCESSFUL in 608ms
1 actionable task: 1 executed
> Configure project :
configuring eagerReport
configuring Copy task stageData
> Task :stageData
BUILD SUCCESSFUL in 628ms
1 actionable task: 1 executed

Asking for help --task stageBackup is a request for that task, so its recipe runs, including the named block that set the description:

> Configure project :
configuring eagerReport
configuring Copy task stageBackup
> Task :help
Detailed task information for stageBackup
Path
     :stageBackup (registered in build file 'build.gradle.kts')
Type
     Copy (org.gradle.api.tasks.Copy)
Options
     --rerun     Causes the task to be re-run even if up-to-date.
Description
     Copies the station data to build/backup.
Group
     -
BUILD SUCCESSFUL in 604ms
1 actionable task: 1 executed

The Groovy DSL printed the same lines for every command (the path line says build.gradle instead of build.gradle.kts), apart from the compile warning. The shape to remember is

To…​ Use (lazy) Avoid (eager)

create a task

tasks.register("name") { ... } or tasks.register<Copy>("name") { ... }; Groovy tasks.register('name', Copy) { ... }

tasks.create(...), and in Groovy the shorthand task name(type: Copy) { ... }

configure one task by name

tasks.named("name") { ... } or tasks.named<Copy>("name") { ... }

tasks.getByName(...), the getAt operator, findByName

configure all tasks of a type

tasks.withType<Copy>().configureEach { ... }

tasks.all { }, tasks.withType<Copy> { } (with a block), whenTaskAdded, matching { }

keep a reference

the TaskProvider that register returns (val stageData = tasks.register(...))

looking the task up again by name just to get an object

depend on a task

pass the provider or the name: dependsOn(stageData), from(stageData)

calling get() on the provider while the build is configured

Two further rules from the migration guide are worth keeping. A configuration block of the lazy API should only change the task it belongs to (not other tasks or the project), because it may run at any time or never. And an iteration over the whole task collection (a for loop, forEach, findAll) or remove forces every task into existence, so avoid them in build logic that has to stay fast.

Removed task syntax

Two spellings from older builds no longer compile. Both were run on 9.8.1.

The first is the left-shift form task name << { ... } that was the standard way to add an action in the first Gradle releases. It was removed in Gradle 5.0 (the upgrade guide for 4.x lists it among the changes that arrive as deprecation warnings in 4.10 and stop working in 5.0); doLast replaces it. The Groovy DSL build file

task hello << {
    println 'Hello from a Gradle 1.x task'
}

fails during configuration, and the fix is tasks.register('hello') { doLast { ... } }. This was run without the configuration cache, because the build fails before the cache matters:

* Where:
Build file '/home/dev/tidepool/build.gradle' line: 1
* What went wrong:
A problem occurred evaluating root project 'tidepool'.
> Could not find method leftShift() for arguments [build_2eo2j9lhbmxnm2dhmoext2o6v$_run_closure1@40c38b20] on task ':hello' of type org.gradle.api.DefaultTask.

The second is the Kotlin DSL notation "name"() that referenced a task by a string. It was deprecated in the 8.x line (the upgrade guide for 8.x announces its removal in 9.0.0) and removed in Gradle 9.0.0; use named("name"). Inside a tasks { } block, the line "hello"() now gives a script compilation error (this was run once without the configuration cache, with the path prefix of the build file replaced):

e: file:///home/dev/tidepool/build.gradle.kts:4:12: No value passed for parameter 'configuration'.
FAILURE: Build failed with an exception.
* Where:
Build file '/home/dev/tidepool/build.gradle.kts' line: 4
* What went wrong:
Script compilation error:
  Line 4:     "hello"()
                     ^ No value passed for parameter 'configuration'.
1 error

The related form "name" { ... } inside a tasks { } block, which configures a task of that name, is not part of the removal and still works in 9.8.1. The Groovy DSL and the Groovy task name { } shorthand were not affected; the shorthand still creates a task eagerly, so it is better to switch it to tasks.register.

Dependencies and ordering

Four methods connect tasks, and they answer two different questions. Dependency methods decide what runs; ordering methods decide in which order, if both run anyway.

Method Meaning Adds the other task to the build?

dependsOn(x)

x must run, and run first, before this task.

Yes.

mustRunAfter(x)

If both tasks are in the build, this task runs after x. A hard ordering rule.

No. Running this task alone does not run x.

shouldRunAfter(x)

The same, but only a preference: Gradle ignores it when honouring it would create a cycle, and in a parallel build when every other dependency of the task is satisfied and only the "should run after" task is still pending.

No.

finalizedBy(x)

x runs after this task, whether this task succeeds or fails, and is added to the build automatically.

Yes (the finalizer).

All of them accept a task provider, a task name, or anything else that dependsOn accepts. The next build has four tasks. publish needs fetch, must run after validate when both are requested, and is finalized by notify. Two Gradle properties switch on a failing publish and a circular dependency, so that the same file shows the error cases:

Kotlin DSL
fun step(name: String) = tasks.register(name) {
    doLast { println("running $name") }
}

val fetch = step("fetch")
val validate = step("validate")
val publish = step("publish")
val notify = step("notify")

publish {
    dependsOn(fetch)         // publish needs fetch, so fetch joins the graph
    mustRunAfter(validate)   // if validate is also requested, it goes first
    finalizedBy(notify)      // notify runs after publish, even if publish fails
}

if (providers.gradleProperty("failPublish").isPresent) {
    publish { doLast { throw GradleException("publish failed on purpose") } }
}

if (providers.gradleProperty("cycle").isPresent) {
    fetch { dependsOn(publish) }
}
Groovy DSL
def step(String name) {
    tasks.register(name) {
        doLast { println "running ${name}" }
    }
}

def fetch = step('fetch')
def validate = step('validate')
def publish = step('publish')
def notify = step('notify')

publish.configure {
    dependsOn fetch         // publish needs fetch, so fetch joins the graph
    mustRunAfter validate   // if validate is also requested, it goes first
    finalizedBy notify      // notify runs after publish, even if publish fails
}

if (providers.gradleProperty('failPublish').present) {
    publish.configure { doLast { throw new GradleException('publish failed on purpose') } }
}

if (providers.gradleProperty('cycle').present) {
    fetch.configure { dependsOn publish }
}

Running publish alone pulls fetch in through dependsOn, and adds notify through finalizedBy; validate is not part of the build, because mustRunAfter does not add anything:

./gradlew --console=plain publish
> Task :fetch
running fetch
> Task :publish
running publish
> Task :notify
running notify
BUILD SUCCESSFUL in 802ms
3 actionable tasks: 3 executed

When validate is requested as well, it runs before publish, whatever the order on the command line (the next output is from publish validate; validate publish gave the same). Nothing orders fetch and validate against each other, so Gradle is free to run them in either order; in the Kotlin DSL run above fetch ran first and in the Groovy DSL run validate did. Never depend on the order of two tasks that no rule connects:

./gradlew --console=plain publish validate
> Task :fetch
running fetch
> Task :validate
running validate
> Task :publish
running publish
> Task :notify
running notify
BUILD SUCCESSFUL in 835ms
4 actionable tasks: 4 executed

A finalizer runs even when the finalized task fails, which is what makes it suitable for cleanup (stopping a server, deleting a temporary directory). With -PfailPublish, publish throws; notify still runs, and the failure message names the task and, since Gradle 9.5, where it was registered (see Task provenance):

./gradlew --console=plain publish -PfailPublish
> Task :fetch
running fetch
> Task :publish FAILED
running publish
> Task :notify
running notify
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':publish' (registered in build file 'build.gradle.kts').
> publish failed on purpose
BUILD FAILED in 798ms
3 actionable tasks: 3 executed

The Groovy DSL run printed the same lines, with a Where: line in front that names the build file and the line of the failing action. Finally, -Pcycle makes fetch depend on publish, which is a cycle. Gradle refuses to run anything and prints the cycle; the error occurs while the task graph is calculated, so there is no configuration to cache:

./gradlew --console=plain publish -Pcycle
FAILURE: Build failed with an exception.
* What went wrong:
Circular dependency between the following tasks:
:fetch
\--- :publish
     \--- :fetch (*)
(*) - details omitted (listed previously)
BUILD FAILED in 903ms

The difference between mustRunAfter and shouldRunAfter shows in a two-task build where each task should run after the other, which is an ordering cycle. The shouldRunAfter pair is accepted and Gradle silently drops one rule; the mustRunAfter pair is the same circular dependency error as above:

Kotlin DSL
val a = tasks.register("a") { doLast { println("running a") } }
val b = tasks.register("b") { doLast { println("running b") } }

val strict = providers.gradleProperty("strict").isPresent

a {
    if (strict) mustRunAfter(b) else shouldRunAfter(b)
}
b {
    if (strict) mustRunAfter(a) else shouldRunAfter(a)
}
Groovy DSL
def a = tasks.register('a') { doLast { println 'running a' } }
def b = tasks.register('b') { doLast { println 'running b' } }

def strict = providers.gradleProperty('strict').present

a.configure {
    if (strict) { mustRunAfter b } else { shouldRunAfter b }
}
b.configure {
    if (strict) { mustRunAfter a } else { shouldRunAfter a }
}
./gradlew --console=plain a b
> Task :a
running a
> Task :b
running b
BUILD SUCCESSFUL in 1s
2 actionable tasks: 2 executed
> Task :b
running b
> Task :a
running a
BUILD SUCCESSFUL in 710ms
2 actionable tasks: 2 executed

Which of the two rules is dropped is not specified: the Kotlin DSL run executed a then b, the Groovy DSL run b then a. With -Pstrict both DSLs fail with the cycle error:

FAILURE: Build failed with an exception.
* What went wrong:
Circular dependency between the following tasks:
:a
\--- :b
     \--- :a (*)
(*) - details omitted (listed previously)
BUILD FAILED in 901ms

Wire inputs instead of using dependsOn

dependsOn between two tasks with actions is the coarsest way to express a relationship: it says "this task needs that task to have run" and nothing about which files it needs. Gradle must then assume that every file the first task produces is an input of the second, which can cause tasks to rerun when an unrelated output changes. The best practices guide therefore reserves dependsOn for lifecycle tasks (as in the dataset task above) and recommends connecting actionable tasks through inputs and outputs, as packData did with from(stageData). With a custom task class this means declaring an @InputFile property and setting it from the output property of the producer; Gradle derives the dependency and the up-to-date check from that wiring (see Writing Custom Tasks and Lazy Configuration & Providers). A related guideline says not to hard-code task names unless they are documented as public API, because most task names of Gradle and of third-party plugins are internal details that can be renamed or removed; prefer a plugin’s DSL, then task types, then names. Passing a task provider, as from(stageData) does, avoids a name altogether.

In the build of "Listing tasks, groups and descriptions" above, stationReport used dependsOn(stageData) on purpose, because it is a simple script task that reads a file in doLast and has no declared inputs. In a real build the report would be a task class whose input is the staged directory.

Skipping and limiting tasks

Gradle offers several ways to keep a selected task from doing its work. They differ in what the build log shows and in whether dependants are affected.

Mechanism Effect Log label

onlyIf { ... }

The predicate is evaluated just before execution; false skips the task’s actions. It can carry a reason string (onlyIf("reason") { ... }) that --info prints.

SKIPPED

enabled = false

The task never runs its actions.

SKIPPED

throw StopExecutionException()

Thrown from an action, it stops that action and the following ones of the task; the build goes on with the next task.

none (the task counts as executed)

-x taskName / --exclude-task

Removes the named task from the build; tasks that only it needed also drop out of the log (see below). The user guide calls this a practice to avoid and recommends a suitable lifecycle task instead.

The task does not appear in the log.

timeout

Interrupts the task when it runs too long and fails it.

FAILED

This build has one example of each (the Gradle properties let the same file show the skipped and the executed cases):

Kotlin DSL
import java.time.Duration

val upload = tasks.register("upload") {
    doLast { println("uploading") }
}

upload {
    val offline = providers.gradleProperty("offline")
    onlyIf("the 'offline' property is not set") { !offline.isPresent }
}

tasks.register("legacyExport") {
    enabled = false
    doLast { println("never printed") }
}

tasks.register("guarded") {
    doFirst {
        throw StopExecutionException("nothing to do")
    }
    doLast { println("never printed either") }
}

tasks.register("slow") {
    timeout = Duration.ofMillis(500)
    doLast { Thread.sleep(60_000) }
}

tasks.register("afterSlow") {
    mustRunAfter("slow")
    doLast { println("afterSlow ran") }
}
Groovy DSL
import java.time.Duration

def upload = tasks.register('upload') {
    doLast { println 'uploading' }
}

upload.configure {
    def offline = providers.gradleProperty('offline')
    onlyIf("the 'offline' property is not set") { !offline.present }
}

tasks.register('legacyExport') {
    enabled = false
    doLast { println 'never printed' }
}

tasks.register('guarded') {
    doFirst {
        throw new StopExecutionException('nothing to do')
    }
    doLast { println 'never printed either' }
}

tasks.register('slow') {
    timeout = Duration.ofMillis(500)
    doLast { Thread.sleep(60_000) }
}

tasks.register('afterSlow') {
    mustRunAfter 'slow'
    doLast { println 'afterSlow ran' }
}

With no property upload runs. With -Poffline the predicate is false and the task is SKIPPED, and --info prints the reason; legacyExport is enabled = false, so it is always SKIPPED:

./gradlew --console=plain upload
./gradlew --console=plain upload -Poffline
./gradlew --console=plain legacyExport
> Task :upload
uploading
BUILD SUCCESSFUL in 825ms
1 actionable task: 1 executed
> Task :upload SKIPPED
BUILD SUCCESSFUL in 770ms
> Task :legacyExport SKIPPED
BUILD SUCCESSFUL in 1s

The onlyIf predicate reads the property through a Provider (providers.gradleProperty(...)) that is captured outside the lambda, so the predicate does not touch the project object when it runs; that is what keeps it compatible with the configuration cache. The Groovy script does the same.

guarded throws StopExecutionException in its doFirst, so its doLast never runs, yet the build carries on and afterSlow runs; the task is shown without a label, as executed:

./gradlew --console=plain guarded afterSlow
> Task :guarded
> Task :afterSlow
afterSlow ran
BUILD SUCCESSFUL in 1s
2 actionable tasks: 2 executed

A timeout interrupts the thread that runs the task, so it works for built-in tasks and for any code that responds to interrupts (Thread.sleep does). The task is FAILED and finalizers still run. Without --continue the rest of the build is abandoned; with --continue, tasks that do not depend on the failed one carry on. This build was run once for each form:

./gradlew --console=plain slow afterSlow
./gradlew --console=plain --continue slow afterSlow
> Task :slow FAILED
Requesting stop of task ':slow' as it has exceeded its configured timeout of 500ms.
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':slow' (registered in build file 'build.gradle.kts').
> Timeout has been exceeded
BUILD FAILED in 1s
1 actionable task: 1 executed
> Task :slow FAILED
Requesting stop of task ':slow' as it has exceeded its configured timeout of 500ms.
> Task :afterSlow
afterSlow ran
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':slow' (registered in build file 'build.gradle.kts').
> Timeout has been exceeded
BUILD FAILED in 1s
2 actionable tasks: 2 executed

Only the first run’s output is shown in the Kotlin DSL form; the Groovy DSL printed the same lines. An unresponsive task, for example a long CPU loop without interruption checks, cannot be timed out.

To leave out a task from the command line use -x (or --exclude-task). Excluding a task also excludes dependencies that only that task needed. With stationReport depending on stageData:

./gradlew --console=plain stationReport -x stageData
> Task :stationReport
3 stations staged
BUILD SUCCESSFUL in 873ms
1 actionable task: 1 executed

stageData does not appear at all, and stationReport ran against the files left by an earlier run. Excluding a task also removes the tasks that only it needed: in the tidepool-model module, build -x test (run with the same options) did not list test and also did not list compileTestJava, testClasses or the test resource task:

> Task :tidepool-model:check UP-TO-DATE
> Task :tidepool-model:processResources NO-SOURCE
> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tidepool-model:classes UP-TO-DATE
> Task :tidepool-model:jar UP-TO-DATE
> Task :tidepool-model:assemble UP-TO-DATE
> Task :tidepool-model:build UP-TO-DATE
BUILD SUCCESSFUL in 6s
2 actionable tasks: 2 up-to-date

Excluding a task is a way to speed up a local build, and it is dangerous when the excluded task produces something the others need; CI should not use it to hide failures.

Task provenance

Since Gradle 9.5 (2026-04-28) Gradle records where each task was registered: in a build script, in the settings script, or by a plugin. The information appears in failure messages, in help --task, and in tasks --provenance. The failure messages above already showed it for script-registered tasks:

Execution failed for task ':publish' (registered in build file 'build.gradle.kts').

For a task registered by a plugin the text names the plugin class. A compilation error in tidepool-model (a deliberately broken source file added to a scratch copy) gave

Execution failed for task ':tidepool-model:compileJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').

tasks --provenance adds the same information to the listing, which answers the common question "which plugin gave me this task?":

./gradlew --console=plain :tidepool-model:tasks --provenance --group=build
> Task :tidepool-model:tasks
------------------------------------------------------------
Tasks runnable from project ':tidepool-model'
------------------------------------------------------------
Build tasks
-----------
assemble (registered by plugin class 'org.gradle.language.base.plugins.LifecycleBasePlugin') - Assembles the outputs of this project.
build (registered by plugin class 'org.gradle.language.base.plugins.LifecycleBasePlugin') - Assembles and tests this project.
buildDependents (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin') - Assembles and tests this project and all projects that depend on it.
buildNeeded (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin') - Assembles and tests this project and all projects it depends on.
classes (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin') - Assembles main classes.
clean (registered by plugin class 'org.gradle.language.base.plugins.LifecycleBasePlugin') - Deletes the build directory.
jar (registered by plugin class 'org.gradle.api.plugins.JavaPlugin') - Assembles a jar archive containing the classes of the 'main' feature.
testClasses (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin') - Assembles test classes.
To see all tasks and more detail, run gradlew tasks --all
To see more detail about a task, run gradlew help --task <task>
BUILD SUCCESSFUL in 767ms
1 actionable task: 1 executed

help --task jar names the plugin for each project that has the task; the Paths heading appears because three projects have a jar task:

> Task :tidepool-model:help
Detailed task information for jar
Paths
     :tidepool-cli:jar (registered by plugin class 'org.gradle.api.plugins.JavaPlugin')
     :tidepool-engine:jar (registered by plugin class 'org.gradle.api.plugins.JavaPlugin')
     :tidepool-model:jar (registered by plugin class 'org.gradle.api.plugins.JavaPlugin')
Type
     Jar (org.gradle.api.tasks.bundling.Jar)
Options
     --rerun     Causes the task to be re-run even if up-to-date.
Description
     Assembles a jar archive containing the classes of the 'main' feature.
Group
     build
BUILD SUCCESSFUL in 816ms
1 actionable task: 1 executed

Provenance is omitted from verification failures such as test failures, because those are expected outcomes and not configuration problems. The same build on Gradle 8.14 does not print provenance (it is new in 9.5; not run here).

Gradle 8.14 and Gradle 9

The task model is stable across the two lines. The differences that matter when a build moves from 8.14 to 9.8.1 are (the 8.14 column is from the official documentation and release notes, not run here):

Topic Gradle 8.14 Gradle 9.8.1

"name"() task reference in the Kotlin DSL

Deprecated since the 8.x line; works with a warning (documentation).

Removed in 9.0.0; use named("name") (run above).

tasks.create in the Kotlin DSL

Eager API, recommended against, and deprecated in the TaskContainer Javadoc since 8.12, so the Kotlin DSL already prints the same compile-time deprecation warning (documentation).

Still available; the Kotlin DSL prints a deprecation warning at compile time (run above).

Task provenance

Not available.

Failure messages, help --task and tasks --provenance (9.5).

Project.exec and Project.javaexec in task actions

Deprecated since 8.11.

Removed in 9.0.0; use an injected ExecOperations or an Exec task.

Task.project at execution time

Deprecated, and the configuration cache reports it.

Same, and the configuration cache is the preferred mode in 9.x (see Configuration Cache & Isolated Projects).

Copy and Sync destination

Only the plain destinationDir file property.

A lazy destinationDirectory property as well (9.8.0, incubating).

Equivalents in Ant and Maven

Gradle Maven Ant

Task (an object with inputs, outputs and actions)

Plugin goal (plugin:goal) bound to a lifecycle phase; see Maven Plugins & Their Configuration.

Task element inside a target; the unit that is scheduled is the target, see Ant Targets & Extension Points.

Lifecycle task (build, assemble, check)

Lifecycle phase (package, verify); see Maven Build Lifecycle.

A target with only a depends attribute and no tasks.

dependsOn

Implicit: a phase runs all earlier phases; goals run in the order of the bindings.

The depends attribute of a target.

mustRunAfter, shouldRunAfter

No equivalent; the phase order is fixed.

No equivalent; the order is the order of depends.

finalizedBy

No equivalent in the lifecycle; a failing goal stops the build (--fail-at-end lets other modules continue).

No equivalent in core Ant; the third-party Ant-Contrib trycatch task has a finally block.

onlyIf, enabled

skip parameters on most plugins, profiles, -D...skip=true.

The if and unless attributes of a target.

-x task

-D skip properties (such as -DskipTests); no general exclusion of a goal.

No general equivalent.

UP-TO-DATE, FROM-CACHE

No general per-goal up-to-date check in core Maven; the Apache Maven Build Cache Extension adds caching (see Build Performance: Cache, Parallel Builds & mvnd).

The uptodate task and the depend task by hand.

Where the books are outdated

Gradle in Action (Muschko, Manning 2014) was written for Gradle 1.7. Its model of tasks (a task is an object with actions, dependencies and a type) is still right, but some of what Gradle 1.7 allowed no longer works or is no longer advised:

  • task name << { ... }. The left-shift operator was the short way to add an action in the Gradle 1.x era. It was removed in Gradle 5.0; use doLast { ... } or, better, tasks.register("name") { doLast { ... } } (run above).

  • Eager task creation. The only task API in Gradle 1.7 was eager (task name(type: Copy), tasks.create, tasks.getByName). The lazy tasks.register and tasks.named arrived in Gradle 4.9 (2018) and are the recommended API now.

  • dependsOn as the main wiring tool. Prefer inputs and outputs wired through properties; dependsOn stays right for lifecycle tasks.

  • The Kotlin DSL. Builds from that era are Groovy only. The Kotlin DSL has been stable since Gradle 5.0 and is the default for gradle init since 8.2, and its "name"() notation is gone (run above).

For the same topics in the other tools see Maven Build Lifecycle and Ant Targets & Extension Points; for the next steps in Gradle see Writing Custom Tasks and Incremental Build.