Kotlin DSL

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 (released 2026-10-07), which embeds Kotlin 2.4.10 and compiles Kotlin DSL scripts with Kotlin language version 2.2. Every example was run through the 9.8.1 wrapper on 2026-10-16, on macOS, with a JDK 26 daemon, and the tidepool modules compile with a Java 21 toolchain. Examples that differ between the two DSLs show the Kotlin DSL first and the Groovy DSL second, and both were run. Each example was run with --configuration-cache unless the text says it demonstrates a problem, and says whether it is compatible. Gradle 8.14 was not available here: what the text says about 8.14 comes from the release notes and the user manual and is labelled "not run here". The scratch directory is shown as <work>.

The Kotlin DSL is Gradle’s second way to write build scripts: the same Gradle API as the Groovy DSL, but in a statically typed language. The Gradle best practices recommend it for new builds, and gradle init has defaulted to it since 8.2 (see Installation). The Groovy side is on Groovy DSL & Migrating to Kotlin; what a settings file and a build file contain is on Settings & Build Scripts. The language itself is covered in the Kotlin section, where Build and Tooling shows how a Kotlin project uses it and Type-Safe Builders and DSLs explains the lambda-with-receiver mechanism that every plugins {} or dependencies {} block is built on.

What the Kotlin DSL is, and how it got here

A Kotlin DSL script is Kotlin code that Gradle compiles with an embedded Kotlin compiler during the configuration phase and then runs against an implicit receiver: a Project for build.gradle.kts, a Settings for settings.gradle.kts, a Gradle for an init script. There is no separate language: plugins {}, dependencies {} and tasks.register("x") { ... } are ordinary function calls whose last argument is a lambda with a receiver. This is the difference from the Groovy DSL, where the same blocks are method calls on a dynamic object that Gradle decorates at run time.

Release Change for the Kotlin DSL

3.0 (2016-08-15)

first support for Gradle Script Kotlin, a Kotlin-based build language (Groovy stays the primary one)

5.0 (2018-11-26)

Kotlin DSL 1.0, "production-ready" in the release notes; accessors for configurations became NamedDomainObjectProvider values

8.1 (2023-04-12)

= assignment of lazy properties, opt-in; libraries and bundles of a version catalog in the plugins {} block deprecated

8.2 (2023-06-30)

= assignment on by default; Kotlin DSL is the default for gradle init

8.12 to 8.14

Kotlin 2.0.21 embedded, still with language version 1.8 (not run here)

9.0.0 (2025-07-31)

Kotlin 2.2 embedded and language version 2.2 for scripts and Kotlin build logic; much better script compilation avoidance; JSpecify nullability annotations in the API; this@Build_gradle labels, "name"() references, eager configuration accessors and catalog libraries in plugins {} removed; kotlinDslPluginOptions.jvmTarget removed

9.6.0 (2026-06-18)

the by registering, by creating, by existing, by getting, by project, by extra delegates deprecated, to be removed in Gradle 10

9.7.0 (2026-08-06)

scripts are compiled against a prebuilt public API jar, so org.gradle.internal.impldep.* imports no longer compile

9.8.0 (2026-09-24)

Kotlin 2.4.10 embedded (language version still 2.2)

The best-practices page says the Kotlin DSL has been the default for gradle init "since Gradle 8.0", while the Gradle 8.2 release notes say that the init task switched to it in 8.2; this page follows the release notes. In the three-tool picture, Ant and Maven scripts are XML, and neither has a typed language for the build file: Maven offers other syntaxes only through the Polyglot extension described on Maven Extensions, and Ant embeds scripting languages inside targets (see Scripting in Ant).

The tidepool build in both DSLs

The tidepool build used on all the Gradle pages has three modules. The command-line module in both DSLs, with a Java 21 toolchain:

Kotlin DSL (tidepool-cli/build.gradle.kts)
plugins {
    application
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

dependencies {
    implementation(project(":tidepool-engine"))
    implementation("info.picocli:picocli:4.7.7")
}

application {
    mainClass = "io.tidepool.cli.Main"
}
Groovy DSL (tidepool-cli/build.gradle)
plugins {
    id 'application'
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

dependencies {
    implementation project(':tidepool-engine')
    implementation 'info.picocli:picocli:4.7.7'
}

application {
    mainClass = 'io.tidepool.cli.Main'
}

Both builds use settings.gradle.kts or settings.gradle with rootProject.name = "tidepool", include("tidepool-model", "tidepool-engine", "tidepool-cli") and a dependencyResolutionManagement block that declares Maven Central once. With the Kotlin DSL build:

./gradlew :tidepool-cli:build --configuration-cache
Calculating task graph as no cached configuration is available for tasks: :tidepool-cli:build
> Task :tidepool-model:compileJava
> Task :tidepool-model:jar
> Task :tidepool-engine:compileJava
> Task :tidepool-engine:jar
> Task :tidepool-cli:compileJava
> Task :tidepool-cli:jar
> Task :tidepool-cli:startScripts
> Task :tidepool-cli:distTar
> Task :tidepool-cli:distZip
> Task :tidepool-cli:assemble
> Task :tidepool-cli:build
BUILD SUCCESSFUL in 654ms
9 actionable tasks: 9 executed
Configuration cache entry stored.

The listing keeps only the tasks that did work (the NO-SOURCE and UP-TO-DATE lines were left out). The Groovy DSL build ran the same nine tasks and also ended with BUILD SUCCESSFUL and Configuration cache entry stored. Both are compatible with the configuration cache. A second run printed Configuration cache entry reused.

Script file names and implicit imports

Kotlin DSL scripts use the extension *.gradle.kts; Groovy DSL scripts use *.gradle. The extension is the switch: build.gradle.kts, settings.gradle.kts and init scripts activate the Kotlin DSL, and one build may mix both. A Kotlin build script can apply a Groovy one and the other way round, and different projects of a multi-project build can use either. For the IDE to know which object backs a script, the manual recommends the pattern *.settings.gradle.kts for scripts applied to Settings (including script plugins applied from a settings script) and *.init.gradle.kts or just init.gradle.kts for init scripts. Mixing DSLs is shown in Groovy DSL & Migrating to Kotlin.

Every Kotlin DSL script starts with implicit imports: the default Gradle API imports, the Kotlin DSL API (the packages org.gradle.kotlin.dsl, org.gradle.kotlin.dsl.plugins.dsl and org.gradle.kotlin.dsl.precompile), and java.util.concurrent.Callable, java.util.concurrent.TimeUnit, java.math.BigDecimal, java.math.BigInteger, java.io.File and javax.inject.Inject. The manual warns against using internal Kotlin DSL APIs, because they break when Gradle or a plugin is updated. The script below has no import line and still compiles:

Kotlin DSL
// no import statements: these types come from the implicit imports
val millis = TimeUnit.SECONDS.toMillis(2)
val sum = BigDecimal("0.1").add(BigDecimal("0.2"))
val dirName = File(projectDir, "src").name

tasks.register("imports") {
    val message = "kotlin=${KotlinVersion.CURRENT} millis=$millis sum=$sum dir=$dirName java=${JavaVersion.current()}"
    doLast { println(message) }
}
./gradlew imports -q --configuration-cache
kotlin=2.4.10 millis=2000 sum=0.3 dir=src java=26

KotlinVersion.CURRENT shows the embedded compiler, 2.4.10. The example is compatible with the configuration cache because doLast captures only the string message. The Groovy DSL has the same default imports for the Gradle API (and Groovy’s own java.io, java.util and java.math auto-imports), so there is nothing to compare.

Type-safe model accessors

In the Groovy DSL, configurations.implementation or java { ... } are resolved by name at run time, even though the configuration or extension only exists because a plugin was applied. The Kotlin DSL replaces that dynamic resolution with type-safe model accessors: Kotlin source that Gradle generates for each project script, after the plugins {} block has run, from the model that the plugins contributed. This is why a typo in implementaton("...") is a compile error and why an IDE can complete java.toolchain.languageVersion.

What gets an accessor

For the project build script (and precompiled project script plugins) the manual lists these sets:

Accessor for Example (Java plugin)

dependency and artifact configurations

implementation, runtimeOnly

project extensions and conventions, and extensions on them

sourceSets, java, application

extensions of the dependencies and repositories containers

testImplementation, mavenCentral

elements of the tasks and configurations containers

compileJava, test

elements of project-extension containers

sourceSets.main

For a settings script (and precompiled settings plugins) the accessors are the extensions contributed by settings plugins, such as pluginManagement and dependencyResolutionManagement. Init scripts and script plugins applied with apply(from = ...) have no accessors. The set is fixed right after the plugins {} block, which gives three rules:

  • the plugin must be applied in the plugins {} block of the same script (or a precompiled script plugin);

  • a configuration, task or extension that the script itself creates later has no accessor;

  • a plugin applied from the outside (apply(plugin = ...), a subprojects {} or allprojects {} block in the root script, a script plugin) gives no accessor either.

The accessor report

kotlinDslAccessorsReport prints the Kotlin source of every accessor that the applied plugins contribute: the name, the type and the code that Gradle generated. For tidepool-cli the report is 3 635 lines long, so the example filters it for the accessors used in the build file above:

./gradlew :tidepool-cli:kotlinDslAccessorsReport -q | grep -E 'val .*`(java|application|compileJava|implementation)`'
    val org.gradle.api.Project.`java`: org.gradle.api.plugins.JavaPluginExtension get() =
    val org.gradle.api.Project.`application`: org.gradle.api.plugins.JavaApplication get() =
    val TaskContainer.`compileJava`: TaskProvider<org.gradle.api.tasks.compile.JavaCompile>
    val org.gradle.api.NamedDomainObjectContainer<org.gradle.api.artifacts.Configuration>.`implementation`: NamedDomainObjectProvider<org.gradle.api.artifacts.Configuration>

java is a JavaPluginExtension on Project, compileJava is a TaskProvider<JavaCompile>, and implementation is a NamedDomainObjectProvider<Configuration>, which is the lazy type that keeps the configuration-avoidance promise: an accessor never realizes the task or configuration unless the script calls get(). (The report was run without --configuration-cache; it is a diagnostic task.)

When the accessor is missing

The usual way to lose accessors is apply(plugin = ...) in the script body. The script below is the tidepool engine build with that mistake, and Gradle 9.8.1 fails at script compilation:

Kotlin DSL (does not compile)
apply(plugin = "java-library")

repositories {
    mavenCentral()
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

dependencies {
    implementation("org.apache.commons:commons-csv:1.14.1")
}
> Configure project :
e: file://<work>/labs/apply-broken/build.gradle.kts:7:1: Unresolved reference. None of the following candidates is applicable because of a receiver type mismatch:
val PluginDependenciesSpec.java: PluginDependencySpec
e: file://<work>/labs/apply-broken/build.gradle.kts:8:5: Unresolved reference 'toolchain'.
e: file://<work>/labs/apply-broken/build.gradle.kts:9:9: Unresolved reference 'languageVersion'.
e: file://<work>/labs/apply-broken/build.gradle.kts:14:5: Unresolved reference 'implementation'.

FAILURE: Build failed with an exception.
...

The first error is a surprise: java resolves to the plugins {} helper PluginDependenciesSpec.java, because that is the only java the compiler can see. The fix is to use the plugins {} block. When that cannot be done, the standard API replaces each accessor, using the type or the string name that the accessor stood for:

Kotlin DSL (compiles: the standard API instead of accessors)
apply(plugin = "java-library")

repositories {
    mavenCentral()
}

configure<JavaPluginExtension> {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

dependencies {
    "implementation"("org.apache.commons:commons-csv:1.14.1")
}
./gradlew dependencies --configuration compileClasspath -q --configuration-cache
------------------------------------------------------------
Root project 'lab'
------------------------------------------------------------

compileClasspath - Compile classpath for source set 'main'.
\--- org.apache.commons:commons-csv:1.14.1
     +--- commons-io:commons-io:2.20.0
     \--- commons-codec:commons-codec:1.19.0

A web-based, searchable dependency report is available by adding the --scan option.
Without an accessor Use

a dependency configuration

a string: "implementation"("g:a:v"), "api"(project(":x")), or configurations["implementation"] / configurations.named("implementation")

a project extension

configure<JavaPluginExtension> { ... } to configure it, the<SourceSetContainer>() to read it; the type comes from the plugin’s documentation or from kotlinDslAccessorsReport

an element of an extension container

named("main") inside the configure<SourceSetContainer> { ... } block

a task

tasks.named<Test>("test") { ... }; the type is mandatory to see Test-specific properties, and ./gradlew help --task test prints it

The second way to lose an accessor is to create the element after the plugins {} block, which the manual calls out with a custom configuration. In the script below configurations.create("report") is not visible as report(...) in the dependencies block:

Kotlin DSL (does not compile)
plugins {
    `java-library`
}

configurations.create("report")

dependencies {
    report("org.apache.commons:commons-csv:1.14.1")
}
> Configure project :
e: file://<work>/labs/late-config/build.gradle.kts:8:5: Unresolved reference 'report'.

FAILURE: Build failed with an exception.
...
Script compilation error:

  Line 8:     report("org.apache.commons:commons-csv:1.14.1")
              ^ Unresolved reference 'report'.

The remedy used in the Gradle manual’s migration chapter is a local variable bound to the new configuration: report(...) is then an invoke call on that Configuration object inside the dependencies {} block. The script below does that, with a repositories {} block so that the dependency resolves, and prints the configuration from a task. The doLast only reads two values that were copied into local variables, which keeps it compatible with the configuration cache (a second run reused the cache entry):

Kotlin DSL
plugins {
    `java-library`
}

repositories {
    mavenCentral()
}

val report = configurations.create("report")

dependencies {
    report("org.apache.commons:commons-csv:1.14.1")
}

tasks.register("showReport") {
    val name = report.name
    val count = report.dependencies.size
    doLast { println("configuration '$name' declares $count dependency") }
}
configuration 'report' declares 1 dependency

A plugin that is applied only in the parent project does not give the child project accessors. In a two-project build whose root script applies java-library and whose child script has no plugins {} block, the child’s java { ... } failed with the same Unresolved reference errors (this was run; the manual’s sentence about elements contributed by plugins applied by parent projects was not reproduced with a core plugin). Apply the plugin where it is used, or move it into a convention plugin (see Convention Plugins & Sharing Build Logic).

The Groovy DSL has no accessors: the same names are resolved dynamically, so the apply plugin: form and the late-created configuration work without ceremony, and typos surface only when the line runs. That trade is the heart of the choice between the DSLs.

Working with containers

Configurations, tasks, source sets and the containers contributed by plugins (such as the buildTypes container of the Android plugin) are NamedDomainObjectContainer values, and some are PolymorphicDomainObjectContainer. The Kotlin DSL gives each operation a lazy and an eager form:

Operation Lazy (preferred) Eager

find an existing element

tasks.named("check"), tasks.named<Test>("test") { ... }

tasks.getByName("check"), tasks.getByName<Test>("test") { ... }

create a new element

tasks.register("t"), tasks.register<Copy>("t") { ... }

tasks.create("t"), tasks.create<Copy>("t") { ... }

configure all of a type

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

tasks.withType<JavaCompile> { ... } (runs now for existing elements)

use an accessor

tasks.test { ... } (a TaskProvider<Test>; the block is lazy)

tasks.test.get() (realizes the task now)

The example below puts the forms side by side. Each block prints a line during configuration, so the output shows when the block runs. A reused configuration cache entry skips configuration and would print nothing, so these runs used --no-configuration-cache; the same build with --configuration-cache is compatible:

Kotlin DSL
plugins {
    java
}

// register: lazy, the block runs only when the task is needed
tasks.register("lazyHello") {
    println("configuring lazyHello")
    doLast { println("Hello from lazyHello") }
}

// create: eager, the block runs at once
tasks.create("eagerHello") {
    println("configuring eagerHello")
    doLast { println("Hello from eagerHello") }
}

// named: lazy reference to an existing task, with its type
tasks.named<JavaCompile>("compileJava") {
    println("configuring compileJava")
    options.encoding = "UTF-8"
}

// register returns a TaskProvider that can be wired into other tasks
val copyData = tasks.register<Copy>("copyData") {
    println("configuring copyData")
    from("data")
    into(layout.buildDirectory.dir("copied"))
}

tasks.named("check") {
    dependsOn(copyData)
}

// getByName: eager, realizes the task now
tasks.getByName<Test>("test") {
    println("configuring test")
}
Groovy DSL
plugins {
    id 'java'
}

// register: lazy, the block runs only when the task is needed
tasks.register('lazyHello') {
    println 'configuring lazyHello'
    doLast { println 'Hello from lazyHello' }
}

// create: eager, the block runs at once
tasks.create('eagerHello') {
    println 'configuring eagerHello'
    doLast { println 'Hello from eagerHello' }
}

// named: lazy reference to an existing task
tasks.named('compileJava', JavaCompile) {
    println 'configuring compileJava'
    options.encoding = 'UTF-8'
}

// register returns a TaskProvider that can be wired into other tasks
def copyData = tasks.register('copyData', Copy) {
    println 'configuring copyData'
    from 'data'
    into layout.buildDirectory.dir('copied')
}

tasks.named('check') {
    dependsOn copyData
}

// getByName: eager, realizes the task now
tasks.getByName('test') {
    println 'configuring test'
}
./gradlew help -q --no-configuration-cache
./gradlew lazyHello -q --no-configuration-cache
./gradlew check -q --no-configuration-cache
configuring eagerHello
configuring test
configuring eagerHello
configuring test
configuring lazyHello
Hello from lazyHello
configuring eagerHello
configuring test
configuring copyData
configuring compileJava

help configures only the two eager tasks: eagerHello (from create) and test (from getByName). Asking for lazyHello realizes that task alone, and check realizes copyData and compileJava, which are on its path, and never lazyHello. Both DSLs printed exactly the same lines in every run, and the build was run with --warning-mode all without any deprecation warning. On a large build with hundreds of tasks, the difference between the two columns is what task configuration avoidance is for (see Tasks and Lazy Configuration & Providers).

The withType form is a separate case: tasks.withType<JavaCompile> { println(...) } configured compileJava and compileTestJava while help ran, but the tasks.withType<Test>().configureEach { ... } form configured test only when check was run, so prefer configureEach.

The tasks {} block groups several operations without repeating the container name, mixing accessors and the container API:

Kotlin DSL
tasks {
    test {
        testLogging.showStackTraces = true
    }
    val myCheck = register("myCheck") {
        doLast { println("myCheck ran") }
    }
    check {
        dependsOn(myCheck)
    }
}
Groovy DSL
tasks.named('test') {
    testLogging.showStackTraces = true
}
def myCheck = tasks.register('myCheck') {
    doLast { println 'myCheck ran' }
}
tasks.named('check') {
    dependsOn myCheck
}

Running ./gradlew check -q --configuration-cache printed myCheck ran with both DSLs, and both are compatible with the configuration cache.

The by-delegates are deprecated since 9.6

Older Kotlin DSL builds, and the manual’s own Kotlin DSL chapter in earlier releases, bind a container element to a Kotlin val with a delegate: val copyData by tasks.registering(Copy::class), val check by tasks.existing, val test by tasks.getting, and val release by extra("x"). The element’s name is the name of the variable, which couples a Kotlin identifier to a task name. Gradle 9.6 deprecated all of these delegates; they will be removed in Gradle 10. A build that uses three of them:

Kotlin DSL (deprecated syntax)
plugins {
    java
}

val copyData by tasks.registering(Copy::class) {
    from("data")
    into(layout.buildDirectory.dir("copied"))
}

val check by tasks.existing {
    dependsOn(copyData)
}

val release by extra("spring-tide")
./gradlew check --warning-mode all
> Configure project :
The 'val name by registering(Type::class) { }' property delegate syntax has been deprecated. This is scheduled to be removed in Gradle 10. Use 'val element = register<Type>(name) { }' instead.
	at Build_gradle.<init>(build.gradle.kts:5)
The 'val name by existing { }' property delegate syntax has been deprecated. This is scheduled to be removed in Gradle 10. Use 'val element = named(name) { }' instead.
	at Build_gradle.<init>(build.gradle.kts:10)
The 'val name by extra(...)' or 'val name by extra { ... }' property delegate syntax has been deprecated. This is scheduled to be removed in Gradle 10. Use 'extra.set(name, value)' instead.
	at Build_gradle.<init>(build.gradle.kts:14)
...
BUILD SUCCESSFUL in 472ms

Each message in the real output continues with the sentence "Consult the upgrading guide for further information" and a link; those sentences were left out here. The upgrade guide gives the replacement for each delegate:

Deprecated Replacement

val x by container.registering(Type::class) { }

val x = container.register<Type>("x") { }

val x by container.creating(Type::class) { }

val x = container.create<Type>("x") { }

val x by container.existing

val x = container.named("x")

val x by container.getting

val x = container.getByName("x")

val p: String by project

val p = project.property("p"); val p: String? by project becomes project.findProperty("p")

val p: String by settings (a Gradle property)

val p = providers.gradleProperty("p").get()

val v by extra("value")

extra.set("v", value)

val v: String by extra

val v = extra["v"] as String

val x: String by myProperty (a Property<T>)

val x = myProperty.get()

Besides the deprecation, the delegate hides a trap: the element name comes from the variable. In an earlier version of the example above, the line val checked by tasks.existing failed at configuration with Task with name 'checked' not found in root project 'lab'. The explicit calls (named("check")) have no such coupling.

The "name"() syntax and other removals in 9.0

Gradle 9.0.0 also removed the "name"() syntax that referenced a task or another domain object by a string at the top level of a script. A top-level "compileJava"() { ... } no longer compiles:

Kotlin DSL (does not compile in 9.8.1)
plugins {
    java
}

"compileJava"() {
    options.encoding = "UTF-8"
}
> Configure project :
e: file://<work>/labs/namecall/build.gradle.kts:5:1: None of the following candidates is applicable:
fun <T : Any> Action<in T>.invoke(target: T): Unit:
...
* What went wrong:
Script compilation errors:

  Line 5: "compileJava"() {
          ^ None of the following candidates is applicable:
...

Use tasks.named<JavaCompile>("compileJava") { ... }, or the accessor tasks.compileJava { ... }. The string form that survives is the invoke inside a container block (dependencies { "implementation"(...) } ran without warnings on 9.8.1 in the examples on this page). The same release removed the eager accessor extensions such as configurations.compileClasspath.files (write .get().files) and made a library or bundle of a version catalog in the plugins {} block a build failure. In Gradle 8.14 the "name"() syntax still worked (not run here).

Properties: lazy assignment and extra properties

Assigning with =

Most task and extension inputs in the current API are lazy Property, ListProperty, MapProperty and ConfigurableFileCollection values. They are read-only references with a set method, which in the Groovy DSL was always assignable with = and in Kotlin needed .set(...). Since Gradle 8.1 (opt-in) and 8.2 (default) the Kotlin DSL turns an assignment to a publicly final property of one of those types into a call that sets the value, so = is the preferred way to call set(). The manual says this works for properties that have no public setter and are typed Property or ConfigurableFileCollection; the example below also assigns a ListProperty and a RegularFileProperty and compiles on 9.8.1.

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

    @get:Input
    abstract val constituents: ListProperty<String>

    @get:InputFiles
    abstract val csv: ConfigurableFileCollection

    @get:OutputFile
    abstract val report: RegularFileProperty

    @TaskAction
    fun write() {
        report.get().asFile.writeText(
            "station=${station.get()}\nconstituents=${constituents.get()}\ncsv=${csv.files.map { it.name }}\n"
        )
    }
}

tasks.register<StationReport>("stationReport") {
    station.set("SF")                                  // classic form
    station = "SF"                                     // lazy assignment, same effect
    constituents = listOf("M2", "S2", "K1")            // ListProperty
    csv.from("data/stations.csv")                      // ConfigurableFileCollection
    csv = files("data/stations.csv")                   // assignment replaces the collection
    report = layout.buildDirectory.file("report.txt")  // RegularFileProperty
    doLast { println(report.get().asFile.readText()) }
}
Groovy DSL
abstract class StationReport extends DefaultTask {
    @Input
    abstract Property<String> getStation()

    @Input
    abstract ListProperty<String> getConstituents()

    @InputFiles
    abstract ConfigurableFileCollection getCsv()

    @OutputFile
    abstract RegularFileProperty getReport()

    @TaskAction
    void write() {
        report.get().asFile.text =
            "station=${station.get()}\nconstituents=${constituents.get()}\ncsv=${csv.files*.name}\n"
    }
}

tasks.register('stationReport', StationReport) {
    station.set('SF')                                  // classic form
    station = 'SF'                                     // assignment, same effect
    constituents = ['M2', 'S2', 'K1']                  // ListProperty
    csv.from 'data/stations.csv'                       // ConfigurableFileCollection
    csv = files('data/stations.csv')                   // assignment replaces the collection
    report = layout.buildDirectory.file('report.txt')  // RegularFileProperty
    doLast { println report.get().asFile.text }
}
./gradlew stationReport -q --configuration-cache --warning-mode all
station=SF
constituents=[M2, S2, K1]
csv=[stations.csv]

Both DSLs printed the same text, and neither printed a deprecation warning. Both are compatible with the configuration cache: the task reads only its own properties. The assignment is type-checked: station = 42 is a compile error whose message names the assign operator that the Kotlin DSL plugs in, not the set method:

> Configure project :
e: file://<work>/labs/assign-fail/build.gradle.kts:7:5: None of the following candidates is applicable:
fun <T : Any> Property<T>.assign(value: T?): Unit:
  Argument type mismatch: actual type is 'Int', but 'String?' was expected.
fun <T : Any> Property<T>.assign(value: Provider<out T>): Unit:
  Argument type mismatch: actual type is 'Int', but 'Provider<out uninferred T (of fun <T : Any> Property<T>.assign)>' was expected.
e: file://<work>/labs/assign-fail/build.gradle.kts:7:13: No applicable 'assign' function found for '=' overload

Two details from the manual: a property with a custom setter does not get the assignment treatment, so the guidance for plugin authors is to declare lazy properties as abstract getters; and the assignment is supported by IntelliJ IDEA from 2022.3 and Android Studio from Giraffe. The Provider and NamedDomainObjectProvider types follow the same pattern: provider.map { ... } to chain, get() to read, namedObjectProvider.configure { ... } to configure lazily. Gradle 8.14 behaves the same for = (default since 8.2; not run here). More on the lazy types is on Lazy Configuration & Providers.

Extra properties and the script object

Extra properties are a map available on every ExtensionAware object (projects, tasks, settings). The Kotlin DSL exposes it as extra; the Groovy DSL exposes it as ext. The pair below creates a property, reads it, handles an undefined one, and puts a property on a task:

Kotlin DSL
plugins {
    java
}

extra["releaseName"] = "spring-tide"                              // create
val releaseName = extra["releaseName"] as String                   // read and cast
val maybe = if (extra.has("notDefined")) extra["notDefined"] else null   // absent property

tasks.test {
    extra["reportType"] = "dev"                                   // extra property on a task
}

tasks.register("showExtra") {
    // copy script-level values into locals: reading them in doLast would capture the script object
    val release = releaseName
    val missing = maybe
    val type = tasks.test.map { it.extra["reportType"] as String }
    doLast { println("release=$release maybe=$missing reportType=${type.get()}") }
}
Groovy DSL
plugins {
    id 'java'
}

ext.releaseName = 'spring-tide'                                    // create
def releaseName = ext.releaseName                                   // read
def maybe = ext.has('notDefined') ? ext.notDefined : null           // absent property

tasks.named('test') {
    ext.reportType = 'dev'                                         // extra property on a task
}

def reportType = tasks.named('test').map { it.ext.reportType }

tasks.register('showExtra') {
    doLast { println "release=$releaseName maybe=$maybe reportType=${reportType.get()}" }
}
./gradlew showExtra -q --configuration-cache
release=spring-tide maybe=null reportType=dev

Both DSLs printed that line, and the second run reused the configuration cache entry in each. Three classic mistakes came up while building these examples, with the real messages:

  1. as String? does not make an absent property null. extra["notDefined"] as String? failed with Cannot get property 'notDefined' on extra properties extension as it does not exist; test extra.has("notDefined") first.

  2. In a task block, extra is the task’s. Inside tasks.register("report") { ... }, a read of extra["threshold"] failed with Could not create task ':report'. and Cannot get property 'threshold' on extra properties extension as it does not exist, because the receiver of the lambda is the task. Write project.extra["threshold"].

  3. A top-level val read in doLast captures the script object. Gradle reported the problem below for the smallest possible case, which is why the example above copies releaseName into a local val before doLast:

Kotlin DSL (not compatible with the configuration cache)
val releaseName = "spring-tide"

tasks.register("showRelease") {
    doLast { println("release=$releaseName") }
}
> Task :showRelease
release=spring-tide
...
* What went wrong:
Configuration cache problems found in this build.

1 problem was found storing the configuration cache.
- Task `:showRelease` of type `org.gradle.api.DefaultTask`: cannot serialize Gradle script object references as these are not supported with the configuration cache.
  See https://docs.gradle.org/9.8.1/userguide/configuration_cache_requirements.html#config_cache:requirements:disallowed_types
...
Configuration cache entry discarded with 1 problem.

The task ran and printed its line, but the build failed on the configuration cache problem and the cache entry was discarded. Copying the value into a local val inside the register block, as in the example above, makes the problem disappear. Groovy closures capture local def variables differently and the Groovy DSL examples on this page were cache-compatible without that step. The configuration cache is covered on Configuration Cache & Isolated Projects. Reading extra properties of a parent project from a subproject (rootProject.extra["x"]) is accepted, while the implicit parent lookup (val x: String by project in a child script) is deprecated since 9.6 and removed in Gradle 10; the supported ways are providers.gradleProperty(...) and a convention plugin, as described on Settings & Build Scripts.

The kotlin-dsl plugin and precompiled script plugins

Build logic in Kotlin, such as buildSrc, an included build like build-logic, or a binary plugin, is compiled by the kotlin-dsl plugin. It applies the Kotlin plugin, adds kotlin-stdlib, kotlin-reflect and gradleKotlinDsl() to the compileOnly and testImplementation configurations, passes the compiler the same arguments as for scripts, registers the SAM-with-receiver compiler plugin, and enables precompiled script plugins: a file named <id>.gradle.kts under src/main/kotlin becomes a plugin with that id. Each Gradle release is meant to be used with a specific version of the plugin; an unexpected version prints a warning and may cause hard-to-diagnose problems (inside an included build the plugin version is not written at all).

The build below is a build-logic included build with one convention, used by an app module. The kotlin-dsl plugin needs a repository for the Kotlin dependencies:

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

rootProject.name = "tidepool"

include("app")
Kotlin DSL (build-logic/build.gradle.kts)
plugins {
    `kotlin-dsl`
}

repositories {
    mavenCentral()
}
Kotlin DSL (build-logic/src/main/kotlin/tidepool.java-conventions.gradle.kts)
plugins {
    `java-library`
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

tasks.withType<JavaCompile>().configureEach {
    options.encoding = "UTF-8"
}

tasks.test {
    useJUnitPlatform()
}
Kotlin DSL (app/build.gradle.kts)
plugins {
    id("tidepool.java-conventions")
}

Inside the precompiled script, the plugins {} block is what makes java {} and tasks.test work: accessors are generated for precompiled script plugins too, which is the reason to put shared configuration there and not in a subprojects {} block. The build-logic tasks in the build output show the extra work that the plugin does:

./gradlew :app:build --configuration-cache
Calculating task graph as no cached configuration is available for tasks: :app:build
> 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 :app:compileJava
> Task :app:classes
> Task :app:jar
> Task :app:assemble
> Task :app:build
BUILD SUCCESSFUL in 6s
11 actionable tasks: 11 executed
Configuration cache entry stored.

(The NO-SOURCE and UP-TO-DATE lines of the app module were left out.) The example is compatible with the configuration cache. The Groovy equivalent uses the groovy-gradle-plugin plugin in build-logic/build.gradle and a file named tidepool.java-conventions.gradle under src/main/groovy; it ended with BUILD SUCCESSFUL for :app:build (9 actionable tasks, against 11 for the Kotlin variant, because it generates no Kotlin accessors); mixing the two DSLs in one build is shown on Groovy DSL & Migrating to Kotlin.

Groovy DSL (build-logic/build.gradle and the precompiled script)
// build-logic/build.gradle
plugins {
    id 'groovy-gradle-plugin'
}

// build-logic/src/main/groovy/tidepool.java-conventions.gradle
plugins {
    id 'java-library'
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

tasks.withType(JavaCompile).configureEach {
    options.encoding = 'UTF-8'
}

tasks.named('test') {
    useJUnitPlatform()
}

Which Java does the build logic target? The manual says the plugin’s code targets Java 8 by default. On 9.8.1, with a JDK 26 daemon and no toolchain configured for build-logic, the compiled script class had class-file major version 70, which is Java 26: javap -v on Tidepool_java_conventions_gradle.class printed major version: 70. The property that used to set the target, kotlinDslPluginOptions.jvmTarget, was removed in 9.0.0; to fix the target, configure a Java toolchain for the build-logic project. Since a precompiled plugin runs in the Gradle daemon, which needs JVM 17 or later from 9.0 on, a very low target buys nothing.

The compiler arguments that the plugin passes (and that scripts use) are -java-parameters, -jvm-default=enable (Kotlin interface members become default methods for Java and Groovy implementors), -Xsam-conversions=class (anonymous classes instead of invokedynamic, for better configuration cache and incremental build support), and -Xjsr305=strict with -Xjspecify-annotations=strict (strict nullability). Gradle 9.0 switched its API from JSR-305 to JSpecify annotations, and with Kotlin 2.1 or later some formerly valid code stops compiling: an unbounded generic such as fun <T> Provider<T>.some() must become <T : Any>, Property<String?> is no longer allowed, and a Map<String, *> parameter no longer takes nullable values. More on convention plugins and build-logic is on Convention Plugins & Sharing Build Logic and Binary Plugins.

The embedded Kotlin version and language level

Gradle ships kotlin-compiler-embeddable with matching kotlin-stdlib and kotlin-reflect, and the kotlin package from them is visible on the Gradle classpath. The version belongs to the Gradle release, and Gradle only makes backward-incompatible Kotlin upgrades in major releases. The compatibility matrix lists the relevant rows:

Embedded Kotlin Minimum Gradle Kotlin language version

1.3.10

5.0

1.3

1.8.10

8.0

1.8

2.0.21

8.12

1.8

2.2.0

9.0.0

2.2

2.3.0

9.4.0

2.2

2.4.0

9.7.0

2.2

2.4.10

9.8.0

2.2

The same page says Gradle is tested with Kotlin 2.0.0 through 2.4.20-RC2 for the Kotlin Gradle plugin used to compile your project. The Kotlin DSL language level 2.2 applies to scripts and to build logic written in Kotlin, classic and convention plugins alike. Two consequences from the 9.0 upgrade guide: scripts can no longer refer to themselves with labels like this@Build_gradle, this@Settings_gradle or this@Init_gradle (use project, settings or gradle), and support for Kotlin language versions 1.4 to 1.7 in build logic was removed. Plugins built with the Kotlin DSL on Gradle 9.x can only be used on Gradle 8.11 or newer, the first release that embeds Kotlin 2 (to support older Gradle versions, build the plugin with Gradle 8.x), and Kotlin Gradle plugin 2.0.0 is the lowest supported. For forward compatibility, the manual says the main concern is the external Kotlin Gradle plugin against the kotlin-stdlib that Gradle ships, which stays fine while the versions are compatible. Gradle 8.14 embeds Kotlin 2.0.21 with language level 1.8 (not run here).

Interoperability with Groovy

A build can mix languages: Kotlin DSL scripts, Groovy DSL scripts, and tasks and plugins in Java, Groovy and Kotlin. Calling Java from Kotlin and Kotlin from Java is covered by the Kotlin reference; the Groovy boundary has a few Gradle-specific tools.

Groovy closures from Kotlin

Plugins written in Groovy may declare methods that take a Closure and not an Action<T>. Kotlin lambdas convert to Action<T> automatically; for a Closure the DSL offers two helpers, closureOf<T> { ... } and delegateClosureOf<T> { ... }. Which one is right depends on how the Groovy method calls the closure, which cannot be seen from the Kotlin side. This is a small plugin written in Groovy in a build-logic project, a plugin with a method that calls the closure with an argument and one that sets a delegate:

package io.tidepool

import org.gradle.api.Plugin
import org.gradle.api.Project

class LegacyPlugin implements Plugin<Project> {
    void apply(Project project) {
        project.extensions.create('legacy', LegacyExtension)
    }
}

class PackageSpec {
    String name
    String version
}

// A plugin written in Groovy: its methods take a Closure, not an Action
class LegacyExtension {
    final PackageSpec spec = new PackageSpec()

    // calls the closure with the spec as its argument
    void pkg(Closure c) { c.call(spec) }

    // sets the spec as the closure's delegate
    void pkgDelegate(Closure c) {
        c.delegate = spec
        c.resolveStrategy = Closure.DELEGATE_FIRST
        c.call()
    }

    String describe() { "${spec.name}:${spec.version}" }
}

The build-logic build applied kotlin-dsl and groovy and added implementation(localGroovy()); gradlePlugin { plugins { register("legacy") { ... } } } registered the id tidepool.legacy. The consuming script:

Kotlin DSL (app/build.gradle.kts)
import io.tidepool.PackageSpec

plugins {
    id("tidepool.java-conventions")
    id("tidepool.legacy")
}

legacy {
    pkg(closureOf<PackageSpec> {
        name = "tidepool-app"
        version = "1.0.0"
    })
}

tasks.register("describeLegacy") {
    val text = legacy.describe()
    doLast { println("legacy extension: $text") }
}
./gradlew :app:describeLegacy -q --configuration-cache
legacy extension: tidepool-app:1.0.0

All four combinations were run:

Groovy method closureOf<T> { ... } delegateClosureOf<T> { ... }

pkg (c.call(spec))

works

No signature of method: org.gradle.kotlin.dsl.GroovyInteroperabilityKt$delegateClosureOf$1.doCall() is applicable for argument types: (io.tidepool.PackageSpec) ...

pkgDelegate (sets delegate)

Parameter specified as non-null is null: method Build_gradle$1.execute$lambda$0, parameter $this$closureOf

works

The manual’s rule of thumb is that a NullPointerException with closureOf means delegateClosureOf is the right helper. On 9.8.1 the same mismatch showed as the non-null parameter error in the table. Gradle plugins should declare Action<T>, which both languages convert without helpers. For closures used for other purposes, KotlinClosure0, KotlinClosure1 and KotlinClosure2 adapt Kotlin functions with up to two parameters.

withGroovyBuilder and Groovy scripts

When a plugin relies on Groovy metaprogramming, withGroovyBuilder { ... } attaches the Groovy semantics to any object: method calls written as strings ("name"("parameters", 42)), blocks ("blockName" { ... }), named arguments ("another"("name" to "example")) and getProperty, setProperty and invokeMethod. The Ant builder, a dynamic Groovy object, is the standard example. The second part of the script applies a Groovy DSL script that holds the dynamic part:

Kotlin DSL (build.gradle.kts)
tasks.register("antEcho") {
    val outDir = layout.buildDirectory.dir("ant-out").get().asFile
    doLast {
        // the Ant builder is a Groovy object: names become method calls
        ant.withGroovyBuilder {
            "echo"("message" to "hello from AntBuilder")
            "mkdir"("dir" to outDir)
        }
    }
}

// a Groovy script can hold the dynamic parts
apply(from = "legacy.gradle")
Groovy DSL (legacy.gradle)
def message = 'configured by legacy.gradle'

tasks.register('groovyHello') {
    doLast { println "Hello from Groovy: ${message}" }
}
./gradlew antEcho groovyHello --configuration-cache
Calculating task graph as no cached configuration is available for tasks: antEcho groovyHello
> Task :groovyHello
Hello from Groovy: configured by legacy.gradle
> Task :antEcho
[ant:echo] hello from AntBuilder
BUILD SUCCESSFUL in 415ms
2 actionable tasks: 2 executed
Configuration cache entry stored.

A first draft of this example failed the configuration cache check, which is worth knowing: it read layout.buildDirectory inside doLast and project.ext inside the Groovy script’s doLast, and Gradle reported Invocation of 'Task.project' by task ':groovyHello' at execution time is unsupported with the configuration cache. The version above computes the directory at configuration time and uses a Groovy local def, and is compatible. Other rules from the manual: to call a Kotlin function with named arguments from Groovy, pass positional values (and all of them, when the Kotlin function has defaults); to call a Groovy function with named arguments from Kotlin, pass a Map<String, ?> (mapOf("parameterName" to "value")); extension functions are called as static functions with the receiver as first argument (TheTargetTypeKt.kotlinExtensionFunction(receiver, ...) in Groovy). See Scripting in Ant for the other Ant-and-Groovy bridge, and Groovy DSL & Migrating to Kotlin for the migration path.

Compilation warnings

Kotlin DSL scripts are compiled during configuration, and the compiler’s deprecation warnings appear in the console as w: lines. The property org.gradle.kotlin.dsl.allWarningsAsErrors=true in gradle.properties turns them into failures. A script that uses a deprecated Kotlin function:

Kotlin DSL
val title = "tidal".capitalize()

tasks.register("showTitle") {
    doLast { println(title) }
}
./gradlew showTitle
> Configure project :
w: file://<work>/labs/warn/build.gradle.kts:1:21: 'fun String.capitalize(): String' is deprecated. Use replaceFirstChar instead.
> Task :showTitle
Tidal
BUILD SUCCESSFUL in 371ms

With org.gradle.kotlin.dsl.allWarningsAsErrors=true added to gradle.properties, the same run stopped at configuration:

> Configure project :
w: file://<work>/labs/warn/build.gradle.kts:1:21: 'fun String.capitalize(): String' is deprecated. Use replaceFirstChar instead.
FAILURE: Build failed with an exception.
* Where:
Build file '<work>/labs/warn/build.gradle.kts' line: 1
* What went wrong:
Script compilation error:
  Line 1: val title = "tidal".capitalize()
                              ^ 'fun String.capitalize(): String' is deprecated. Use replaceFirstChar instead.
1 error

(Blank lines of the real output were removed.) The warning appears only when the script is compiled: the compiled result is cached, so a second run of an unchanged script does not repeat it, which is why the edit of the string was needed before the second capture. The Groovy DSL has no equivalent switch: Groovy script problems surface when the line runs.

Limitations, performance and IDE support

  • The Kotlin DSL is slower than the Groovy DSL on first use (clean checkouts, ephemeral CI agents), because compiling Kotlin scripts is slower. Changing something in buildSrc invalidates build-script caching too. Script compilation avoidance skips recompiling a script when only the non-ABI part of its classpath changed, and Gradle 9.0 made the ABI-change detection much better. The manual notes known issues, and the system property org.gradle.kotlin.dsl.scriptCompilationAvoidance set to false disables it. These timings were not measured here.

  • In IntelliJ IDEA, the project must be imported from the Gradle model for content assist and refactoring in Kotlin DSL scripts. The manual recommends disabling the automatic build import and enabling the automatic reloading of script dependencies, which gives early feedback while leaving the full sync under your control.

  • The model {} block of the discontinued Gradle Software Model is not supported.

  • Builds with slow configuration time make the IDE less responsive; configuration cache and avoiding configuration-time work (see Build Lifecycle) help both.

IDE support, from the manual:

IDE Build import Syntax highlighting Semantic editor (completion, navigation, refactoring)

IntelliJ IDEA

yes

yes

yes

Android Studio

yes

yes

yes

Eclipse IDE, CLion, Apache NetBeans, Visual Studio Code (LSP)

yes

yes

no

Visual Studio

yes

no

no

If something looks wrong in the editor, run ./gradlew tasks from the command line first: if the command line works, restart the IDE and invalidate its caches. The Kotlin DSL logs are in $HOME/Library/Logs/gradle-kotlin-dsl on macOS, $HOME/.gradle-kotlin-dsl/log on Linux and $HOME/AppData/Local/gradle-kotlin-dsl/log on Windows, and the org.gradle.kotlin.dsl.logging.tapi system property adds detail to the daemon log. Where the Kotlin plugin of the IDE is the cause, report the problem to the IDE vendor’s tracker.

Where the book is outdated

Gradle in Action (Gradle 1.7) is written around the Groovy DSL only, and uses the constructs below. The current state, from the release notes and the user manual:

The book Gradle 9.8.1

Groovy DSL only

Kotlin DSL 1.0 in 5.0; the default for gradle init since 8.2; recommended for new builds

task name << { }

removed in 5.0; tasks.register("name") { doLast { } }

apply plugin: 'java'

the plugins {} block, which is also what unlocks type-safe accessors in the Kotlin DSL

compile, testCompile

removed in 7.0; implementation, api, testImplementation

the maven plugin and uploadArchives

removed in 7.0; maven-publish

Classic problems

Message (Gradle 9.8.1) Cause Fix

Unresolved reference 'implementation'

the plugin was applied with apply(plugin = ...) or in a subprojects {} block, so there is no accessor

use the plugins {} block, or "implementation"(...) and configure<T> { }

Unresolved reference 'report'

a configuration created after the plugins {} block has no accessor

keep a variable: val report = configurations.create("report")

Task with name 'checked' not found in root project 'lab'.

a by tasks.existing delegate takes the task name from the variable name

tasks.named("check") (the delegates are deprecated since 9.6)

Cannot get property 'notDefined' on extra properties extension as it does not exist

the cast as String? does not make a missing extra property null

test extra.has("name")

Cannot get property 'threshold' on extra properties extension as it does not exist

inside a task block, extra is the task’s extra

project.extra["threshold"]

cannot serialize Gradle script object references as these are not supported with the configuration cache

doLast reads a script-level val

copy the value into a local val in the task block

No applicable 'assign' function found for '=' overload

the assigned value does not match the Property type

convert the value, or assign a Provider of the right type

None of the following candidates is applicable for a top-level "compileJava"() { }

the "name"() syntax was removed in 9.0

tasks.named<JavaCompile>("compileJava") { }

Summary

  • Kotlin DSL scripts are *.gradle.kts files compiled during configuration against Project, Settings or Gradle; one build may mix both DSLs.

  • Type-safe accessors exist for what the plugins {} block applied; otherwise use strings and configure<T>, the<T>() and tasks.named<T>.

  • Prefer the lazy container operations (register, named, withType<T>().configureEach); the by registering, by existing, by getting and by extra delegates are deprecated since 9.6.

  • = assigns lazy properties (default since 8.2); copy script-level values into locals before doLast for the configuration cache.

  • Use the kotlin-dsl plugin for build-logic; Gradle 9.8.1 embeds Kotlin 2.4.10 and compiles with language version 2.2.

  • Cross into Groovy with closureOf/delegateClosureOf, withGroovyBuilder or apply(from = "x.gradle").