skip to content

How do you tell whether a piece of build-script code runs at configuration time or execution time, and why does it matter?

level: middleimportance: must knowfreq 60%

answer

  1. script body + config block = config phase
  2. doLast/doFirst/@TaskAction = execution
  3. .get() forces config-time evaluation
  4. println test: fires without running task = config
  5. push work into actions or lazy providers

basics

~20 s

Code directly in the script body or in a task's configuration block runs at configuration time, on every build. Code inside doFirst/doLast/@TaskAction runs at execution time, only when the task runs. It matters because configuration-time work slows every command.

solid answer

~50 s

The rule of thumb: if code is in the **script body** or a **task configuration block** (the closure passed to `register`/`named`), it runs during the **configuration phase** — evaluated every build, for every project. If code is inside a task **action** (`doFirst {}`, `doLast {}`, an `@TaskAction` method), it runs during the **execution phase**, only if that task is actually executed. The classic mistake is doing real work (reading files, calling `project.exec`, resolving a configuration, computing values) directly in the script body; that work then runs on *every* invocation, even `gradle help`, multiplied across modules. The fix is to push such work into task actions or wrap it in lazy `Provider`/`Property` so it's computed only when the value is needed. You can confirm phase by adding a `println` and running a task that does *not* execute the task in question — anything that still prints ran at configuration time.

code

kotlin · 5 lines
kotlin
tasks.register("stamp") {
    val sha = providers.exec { commandLine("git", "rev-parse", "HEAD") }
        .standardOutput.asText  // lazy provider, not resolved yet
    doLast { println(sha.get()) } // resolved only at execution
}

go deeper

for a junior

Match the placement to the phase: body/config block = configuration, doLast/@TaskAction = execution.

for a middle

Explain the per-build cost of configuration-time work and the .get() laziness pitfall; demonstrate with the println test.

for a senior

Diagnose with --profile/build scans and refactor eager script-body work into providers/actions across a codebase.

for a principal

Codify in convention plugins that script bodies stay declarative and lazy; review configuration-time budget as a first-class build-health metric.

## The two phases that matter here - **Configuration phase:** Gradle evaluates each `build.gradle.kts` to build the task model. The *script body executes as code* here. - **Execution phase:** Gradle runs the actions of the tasks it was asked to run. ## The placement rule Where you put code decides its phase: | Placement | Phase | |---|---| | Top-level script body statements | Configuration | | Block passed to `tasks.register("x") { ... }` / `tasks.named("x") { ... }` | Configuration (when realized) | | Setting task properties (`x.outputDir = ...`) | Configuration | | `doFirst { }`, `doLast { }` | Execution | | A custom task's `@TaskAction fun run()` | Execution | ## Demonstration ```kotlin println("1: script body") // configuration tasks.register("build-report") { println("2: configuring report") // configuration (on realization) val out = layout.buildDirectory.file("r.txt") doLast { println("3: writing report") // execution out.get().asFile.writeText("done") } } ``` Run `gradle help`: you see `1` (always) and possibly `2` if the task is realized, but **never** `3` because `build-report` isn't executed. Run `gradle build-report`: you see `3` as well. ## Why it matters Configuration-time code is paid on **every** build invocation and for **every** project (default full configuration). So a single innocent-looking line of real work in a script body becomes a tax on all commands: ```kotlin // ANTI-PATTERN: eager, runs every build for every module val gitSha = providers.exec { commandLine("git", "rev-parse", "HEAD") } .standardOutput.asText.get() // .get() forces it NOW, at config time // BETTER: keep it a lazy Provider; resolve only when a task needs it val gitShaProvider = providers.exec { commandLine("git", "rev-parse", "HEAD") } .standardOutput.asText // no .get() — stays deferred ``` Calling `.get()` on a `Provider` in the script body collapses laziness back to configuration time — a common subtle source of slow builds. ## How to diagnose - Add a `println` and observe whether it fires for a command that doesn't run the task — if so, it's configuration-time. - Use a build scan or `--profile` to see configuration vs execution time breakdown. - Look for `.get()`/`.forEach`/`exec`/file I/O in script bodies — usual culprits. ## Takeaway The phase is determined by *placement*, not intent. Keep script bodies cheap and declarative; do the actual work inside task actions or behind lazy providers so it only runs when truly needed.

  • Why is calling `.get()` on a Provider in the script body a problem?
    It forces the provider to resolve immediately, at configuration time, every build — defeating the laziness. Defer `.get()` into a task action so the value is computed only when the task actually runs.
  • How would you prove a given line runs at configuration time?
    Put a println on it and run a command that does not execute the owning task (e.g. `gradle help`). If it still prints, the line ran during configuration.

saying these in an interview costs you the question

  • Thinking placement inside `register { }` makes code run at execution — that block is configuration.
  • Believing the phase depends on what the code *does* rather than where it sits.
  • Doing file I/O / exec / configuration resolution in the script body and calling it harmless.

context