Gradle Incremental Builds: How Up-to-Date Checks Save Time
Gradle's up-to-date checks skip tasks whose inputs and outputs haven't changed. Learn how to declare them correctly, see a JavaCompile example that avoids recompilation when only resources change, and audit your build with --dry-run and --profile.
06 Aug 2025, 02:43 UTC

The Problem: Rebuilding What Didn't Change
You change a single log statement in a utility class, hit build, and wait while Gradle recompiles the entire module, runs tests, and reassembles the JAR. On a medium-sized Java project that can be 30–60 seconds of idle time—multiplied by dozens of edits per day. The root cause is often not Gradle itself but tasks that either don't declare their inputs and outputs or declare them incorrectly, forcing Gradle to assume the task is stale.
How Gradle Decides a Task Is Up-to-Date
Gradle captures a snapshot of every file (or directory) listed as an input to a task—source files, configuration, environment variables, even the task's own class implementation. It also snapshots the outputs: the files and directories the task writes. On the next invocation Gradle compares the new snapshots with the stored ones. If nothing changed, the task is marked UP-TO-DATE and its actions are skipped entirely. This mechanism works for any task that uses the incremental API, including the built-in JavaCompile, ProcessResources, and Jar tasks.
The snapshots are stored in .gradle//taskHistory and are keyed by the task's full path and a hash of its implementation class. That means changing the build script (e.g., adding a compiler argument) invalidates the snapshot for the affected tasks, which is exactly what you want.
Declaring Inputs and Outputs Correctly
For custom tasks you have two equivalent options: annotate the task class with @InputFile, @InputDirectory, @OutputFile, @OutputDirectory, etc., or use the DSL in build.gradle.kts / build.gradle:
tasks.register("generateMetadata") {
val outputFile = layout.buildDirectory.file("generated/metadata.json")
inputs.file(layout.projectDirectory.file("src/main/resources/version.txt"))
outputs.file(outputFile)
doLast {
outputFile.parentFile?.mkdirs()
outputFile.writeText("version=${inputs.files.singleFile.readText().trim()}")
}
}Key rules:
- Every file or directory the task reads must appear in
inputs. - Every file or directory the task writes must appear in
outputs. - Never use mutable values like
System.currentTimeMillis()or random numbers as inputs—they change on every run and defeat the check. - If a task creates a directory full of files, declare the directory as an
@OutputDirectory(oroutputs.dir) rather than listing each file individually.
Worked Example: JavaCompile Skips When Only Resources Change
Consider a standard Java library module with src/main/java and src/main/resources. The compileJava task declares src/main/java (and any generated sources) as inputs and build/classes/java/main as outputs. The processResources task declares src/main/resources as inputs and build/resources/main as outputs. Because the two tasks have disjoint input sets, changing a file under src/main/resources invalidates only processResources; compileJava remains UP-TO-DATE.
Run this sequence to see it in action (project root, Gradle wrapper required):
./gradlew build --dry-run– observe both tasks markedUP-TO-DATEafter a clean build.- Edit
src/main/resources/application.properties. ./gradlew build --dry-run–compileJavastaysUP-TO-DATE,processResourcesshows asEXECUTING(orNO-SOURCEif no resources).
No source recompilation occurs, saving the full Java compilation time.
Trade-off: Misdeclared Inputs Cause Stale or Over-Eager Builds
If you forget an input (e.g., a generated source directory), Gradle will incorrectly consider the task up-to-date and you'll ship stale class files. Conversely, if you declare an input that changes on every run—like a timestamp file—Gradle will never skip the task, nullifying the benefit. The most common mistake is adding a @Input on a configuration object that contains a mutable field (like a Date), which changes its hash every build.
Action: Audit Your Build with Dry-Run and Profile
Add a quick verification step to your workflow:
- Run
./gradlew build --dry-runafter a clean build. Every task should beUP-TO-DATE. - Make a trivial change in each source set (Java, resources, generated code) and rerun
--dry-run. Only the directly affected tasks should show as executing. - For deeper analysis, run
./gradlew build --profileand open the HTML report underbuild/reports/profile. The "Task Execution" tab shows exact timings and whether a task was skipped.
If a task that should be skipped isn't, run ./gradlew help --task :module:taskName to list its declared inputs and outputs and compare them with what the task actually reads and writes.
Incremental builds are not magic—they're a contract between your task declarations and the file system. Honor that contract and you'll reclaim minutes every hour.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.