skip to content

An ad-hoc task prints a value every single build even when you run an unrelated task. What is going on and how do you fix it?

level: middleimportance: must knowfreq 50%

answer

  1. config phase runs every build
  2. work outside doLast leaks
  3. move into doLast/doFirst
  4. use Provider for lazy values
  5. config cache flags this

basics

~20 s

The print is outside doLast, so it runs in the configuration phase, which executes every build for all projects. Move the side-effecting code inside doLast { ... } so it only runs when the task executes.

solid answer

~50 s

This is the classic **configuration-time leak**. Any code in a `register`/`create` block but *outside* `doFirst`/`doLast` runs during the **configuration phase** — and configuration runs on essentially every invocation, for every task you might run, not just yours. So a `println` placed directly in the block fires even when you run an unrelated task. The fix is to move the work into an action: `doLast { println(...) }`, which defers it to the **execution phase** so it runs only when the task is actually executed. This isn't just cosmetic: configuration-time work slows every build and is one of the main things the **configuration cache** is designed to eliminate. With `tasks.register` (lazy/configuration-avoidance), the configuration block is at least skipped when the task isn't referenced — but anything you've put outside `doLast` will still run as soon as the task is configured for any reason. Keep real work in actions; keep the block to pure, cheap configuration.

code

kotlin · 10 lines
kotlin
// Before (leaks to configuration time):
tasks.register("report") {
    val now = expensiveCompute()   // runs every build
    doLast { println(now) }
}

// After (deferred to execution):
tasks.register("report") {
    doLast { println(expensiveCompute()) }
}

go deeper

for a junior

Recognize the print is outside doLast and move it inside.

for a middle

Explain the configuration vs execution phase and why configuration runs every build across projects.

for a senior

Connect to configuration avoidance vs configuration cache and to using Providers for lazy evaluation.

for a principal

Drive a build-performance standard: no IO/compute at configuration time, providers for laziness, configuration cache enabled and kept green.

## The two phases A Gradle build has distinct phases: 1. **Configuration phase** — Gradle evaluates build scripts and configures the task graph for the build. This runs (broadly) every invocation, across all projects involved. 2. **Execution phase** — Gradle runs the selected tasks' **actions** in dependency order. Code you write directly inside a task-registration block is **configuration code**. Code inside `doFirst`/`doLast` is an **action** and belongs to execution. ```kotlin tasks.register("report") { println("computing") // BUG: runs at configuration, every build doLast { println("computing") // correct: runs only when 'report' executes } } ``` ## Why it 'runs on every build' Even for an unrelated target like `./gradlew compileJava`, Gradle still configures the project (and historically every project) to build the task graph, so your stray `println` executes. Expensive configuration work (network calls, file scans, heavy computation) at this point penalizes *every* build. ## The fix Move side-effecting/expensive work into `doLast` (or `doFirst`). For values you need lazily, compute them with a **`Provider`** so they're evaluated on demand rather than eagerly at configuration time. ## Two related optimizations - **Configuration avoidance** (`tasks.register` vs `tasks.create`): the *configuration block itself* is skipped unless the task is needed — but anything outside `doLast` still runs once the task is configured. - **Configuration cache**: serializes the configured task graph and reuses it across builds, skipping the configuration phase entirely on a hit. It actively flags configuration-time logic (and `project` access at execution time) as incompatible, nudging you toward keeping work in actions and using lazy providers/injected services. ## Rule of thumb The registration block should be cheap and declarative. Anything that *does* something (IO, computation, printing results) goes in `doLast`/`doFirst`.

  • Does using tasks.register instead of tasks.create fix a configuration-time println outside doLast?
    Only partly — register skips the whole block when the task isn't needed, but once the task is configured the stray code still runs. Move it into doLast to truly fix it.
  • How does the configuration cache relate to this bug?
    It skips the configuration phase on a cache hit and flags configuration-time work / execution-time project access as incompatible, pushing you to keep work in actions.
  • How do you compute a value lazily so it isn't evaluated at configuration time?
    Wrap it in a Provider (e.g. provider { ... } or a Property), which is evaluated on demand rather than eagerly.

saying these in an interview costs you the question

  • Thinking switching create→register alone moves the stray code into execution
  • Assuming configuration code only runs for the requested task
  • Doing network/file IO directly in the registration block

context