# Gradle, From Zero

> The flexible build tool behind Java, Kotlin, and Android: tasks and the build graph, the Groovy/Kotlin DSL, dependency configurations, and the build cache.


---

# Gradle, From Zero

You opened a Java or Android project, saw `build.gradle`, ran `./gradlew build`, and a wall of text scrolled past for ninety seconds. Then someone told you to "add a dependency" and you had no idea whether to write `implementation`, `api`, or `compile`, or why your change quietly broke a downstream module. Gradle feels like a black box that occasionally yells at you.

It isn't a black box. Gradle is a small, learnable idea wrapped in a big vocabulary: your build is a graph of tasks, your config is real code, and the speed comes from Gradle refusing to redo work it has already done. Once you see the graph, the rest stops being mysterious.

## How to read this

Go in order. Phase 1 gives you the mental model: tasks, the build graph, and why Gradle exists at all. Phase 2 is the everyday work: writing `build.gradle(.kts)`, applying plugins, and declaring dependencies the right way. Phase 3 is the speed and the sharp edges: incremental builds, the build cache, the wrapper, and the gotchas that bite real teams. Read with a terminal open; run the commands as you go.

## The phases

1. [Phase 1: The Build Is a Graph](01-the-build-is-a-graph.md) - what Gradle actually is, tasks, and the task DAG
2. [Phase 2: The Build Script You Live In](02-the-build-script-you-live-in.md) - the DSL, plugins, and dependency configurations
3. [Phase 3: Why It's Fast, and Where It Bites](03-why-its-fast-and-where-it-bites.md) - incremental builds, the cache, the wrapper, and gotchas


---

# The Build Is a Graph

Here's the reality you're starting from: a "build" sounds like one thing, one button. But turning source code into a shippable artifact is a chain of steps. Compile the code. Process the resources. Run the tests. Bundle everything into a JAR or an APK. Each step needs the step before it to be finished first. You can't bundle a JAR before you've compiled the classes that go in it.

Gradle's whole worldview is built on that ordering. Every step is a **task**, and the tasks form a graph: arrows point from a task to the tasks it depends on. Understand that graph and you understand Gradle. Everything else - the script syntax, the plugins, the caching - is detail hanging off this one idea.

## What a task actually is

A task is a named unit of work with inputs and outputs. `compileJava` takes your `.java` files (inputs) and produces `.class` files (outputs). `test` takes the compiled classes and produces test results. `jar` takes the classes and produces a `.jar`.

You can list every task available in a project:

```console
$ ./gradlew tasks
Build tasks
-----------
assemble - Assembles the outputs of this project.
build - Assembles and tests this project.
clean - Deletes the build directory.

Verification tasks
------------------
check - Runs all checks.
test - Runs the test suite.
```

*What just happened:* Gradle scanned your build script and the plugins it applies, then printed every task they registered, grouped by purpose. You didn't write most of these - the `java` plugin contributed them. Tasks are the vocabulary you'll use for the rest of your Gradle life.

## The graph, made visible

When you run a task, Gradle doesn't run it in isolation. It walks backward through the dependencies, builds an ordered plan, and runs each task exactly once. Ask Gradle to run `build`, and it figures out everything that has to happen first.

```mermaid
graph LR
  A[compileJava] --> B[processResources]
  B --> C[classes]
  A --> C
  C --> D[jar]
  C --> E[test]
  D --> F[assemble]
  E --> G[check]
  F --> H[build]
  G --> H
```

*What just happened:* This is a **DAG** - a directed acyclic graph. "Directed" because the arrows have direction (`build` needs `check`, not the other way round). "Acyclic" because there are no loops; a task can never depend on itself, directly or in a circle. Gradle topologically sorts this graph to decide run order. If two branches don't depend on each other, Gradle is free to run them in parallel.

You can see the plan without running it by using the `--dry-run` flag:

```console
$ ./gradlew build --dry-run
:compileJava SKIPPED
:processResources SKIPPED
:classes SKIPPED
:jar SKIPPED
:assemble SKIPPED
:compileTestJava SKIPPED
:test SKIPPED
:check SKIPPED
:build SKIPPED
```

*What just happened:* `--dry-run` printed the exact execution order Gradle would use, top to bottom, marking each `SKIPPED` because nothing actually ran. This is the resolved DAG flattened into a list. When a build does something you didn't expect, this is the first command to reach for - it shows you what Gradle thinks it's about to do.

## Why a programmable build, not config files

This is where Gradle parts ways with older tools. Maven (covered next door at [/guides/build-and-release-basics](/guides/build-and-release-basics) in spirit) describes a build with XML: a fixed structure you fill in. It's convention-heavy and predictable, which is a real strength. But the moment you need something the XML schema didn't anticipate, you're writing a plugin or fighting the format.

Gradle made the opposite bet. A build script is a **program** - Groovy or Kotlin code that runs and, as it runs, registers tasks and wires up the graph. That's the source of Gradle's reputation for flexibility: if you can express it in code, you can put it in your build.

```groovy
// A custom task, defined in three lines of real code.
tasks.register('greet') {
    doLast {
        println "Building ${project.name} on ${java.time.LocalDate.now()}"
    }
}
```

*What just happened:* You registered a brand-new task named `greet` whose action prints a line. Run `./gradlew greet` and it executes. There was no XML schema to satisfy and no plugin to install - the build script is a place where you can write logic directly. That power is also the danger, which Phase 3 gets into.

> The trade-off in one sentence: Maven gives you convention and predictability; Gradle gives you flexibility and the rope to overcomplicate your build. Most teams want the convention most of the time and the flexibility occasionally - which is why Gradle ships with strong defaults (those plugin-contributed tasks) so you rarely start from a blank file.

## Configuration vs execution: the two phases of every run

One mental model that saves you hours of confusion later: a Gradle run happens in two distinct phases. First **configuration** - Gradle executes your whole build script top to bottom to build the task graph. Then **execution** - Gradle runs the actions of the tasks you asked for.

```groovy
tasks.register('slow') {
    println "This prints during CONFIGURATION, every single build"
    doLast {
        println "This prints during EXECUTION, only when 'slow' runs"
    }
}
```

*What just happened:* The bare `println` runs while Gradle is still building the graph - even if you ran a completely different task. The `println` inside `doLast` runs only when `slow` actually executes. Mixing these up ("why does my expensive code run on every build?") is one of the most common Gradle confusions. Real work belongs inside `doLast` or a task action, never in the bare body.

## In the wild

When a CI build is mysteriously slow or runs the wrong things, experienced engineers reach for `./gradlew <task> --dry-run` and `./gradlew tasks` before reading a single line of the build script. The graph is the ground truth; the script is how the graph got built. Learn to read the graph and you can debug any Gradle project, even one you've never seen.

```quiz
[
  {
    "q": "What does it mean that Gradle's task graph is a DAG?",
    "choices": ["Tasks run in alphabetical order", "It is directed and acyclic, so dependencies have direction and no task can depend on itself in a loop", "Every task runs exactly twice", "Tasks are stored in a database"],
    "answer": 1,
    "explain": "DAG = directed acyclic graph. Arrows have direction and there are no cycles, which lets Gradle sort the tasks into a valid run order."
  },
  {
    "q": "You put a bare println in a task body (not inside doLast). When does it run?",
    "choices": ["Only when that task executes", "Never", "During the configuration phase, on every build", "Only on CI"],
    "answer": 2,
    "explain": "Code in the bare task body runs during configuration, which happens on every build regardless of which task you asked for. Real work belongs in doLast."
  },
  {
    "q": "What is the main philosophical difference between Gradle and Maven?",
    "choices": ["Gradle is faster because it is written in Rust", "Maven uses a programmable build script; Gradle uses fixed XML", "Gradle is a programmable build (code), while Maven favors fixed convention via XML", "There is no difference"],
    "answer": 2,
    "explain": "Gradle's build script is real Groovy/Kotlin code that registers tasks, giving flexibility. Maven favors convention through a fixed XML structure."
  }
]
```


---

# The Build Script You Live In

You'll spend most of your Gradle time in one file: `build.gradle` or `build.gradle.kts`. It's where you declare which plugins to apply, which libraries you depend on, and any custom behavior you need. The day-to-day work isn't writing tasks from scratch - it's reading this file, adding a dependency without breaking anything, and knowing which of the near-identical keywords (`implementation` vs `api`) to pick. This phase makes that file legible.

## Groovy or Kotlin: two dialects, one model

Gradle scripts come in two flavors. `build.gradle` is Groovy. `build.gradle.kts` is Kotlin. They describe the exact same build model - same tasks, same plugins, same dependencies - with slightly different syntax.

```groovy
// build.gradle (Groovy DSL)
plugins {
    id 'java'
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'com.google.guava:guava:33.0.0-jre'
    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.0'
}
```

```kotlin
// build.gradle.kts (Kotlin DSL)
plugins {
    java
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("com.google.guava:guava:33.0.0-jre")
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
}
```

*What just happened:* Both files do the same three things - apply the `java` plugin, point at Maven Central for downloads, and declare two dependencies. The Kotlin version uses parentheses and quotes like a normal function call; the Groovy version is looser. The practical difference: Kotlin gives you autocomplete and compile-time checking in your IDE, which is why newer projects lean toward `.kts`. Pick one per project and stay consistent.

## Plugins: where your tasks come from

Remember from Phase 1 that you didn't write `compileJava` or `test` - a plugin did. Applying a plugin is how you pull in a whole bundle of tasks and conventions. The `java` plugin alone gives you `compileJava`, `test`, `jar`, `build`, and the standard `src/main/java` layout.

```kotlin
plugins {
    java
    application                    // adds the 'run' task
    id("org.jetbrains.kotlin.jvm") version "1.9.22"  // third-party, needs a version
}

application {
    mainClass.set("com.example.Main")
}
```

*What just happened:* Three plugins applied. `java` and `application` are built into Gradle, so they need no version. The Kotlin plugin is third-party, so you pin a version. The `application` plugin contributed a `run` task wired to the `mainClass` you set, so now `./gradlew run` launches your program. Plugins are the main way you get power without writing it yourself.

> The mental shortcut: when you wonder "where did this task come from?", the answer is almost always a plugin. Run `./gradlew tasks` and the grouping hints at which plugin contributed what.

## Dependencies and the repository

A dependency is an external library you want on your classpath. You declare it by its coordinates - `group:name:version` - and Gradle downloads it from the repositories you listed.

```kotlin
repositories {
    mavenCentral()
}

dependencies {
    implementation("org.apache.commons:commons-lang3:3.14.0")
}
```

*What just happened:* You told Gradle to look in Maven Central and to put `commons-lang3` version 3.14.0 on the compile and runtime classpath. Gradle downloads the JAR (and anything *it* depends on - transitive dependencies) into a local cache so the next build doesn't re-download it. You can see the full resolved tree:

```console
$ ./gradlew dependencies --configuration runtimeClasspath
runtimeClasspath
\--- org.apache.commons:commons-lang3:3.14.0
```

*What just happened:* Gradle printed the dependency tree for the runtime classpath. A bigger library would show its transitive dependencies indented underneath. This command is your first stop when you hit a version conflict or a "class not found" error - it shows exactly what's on the classpath and why.

## The keyword that trips everyone: implementation vs api

This is the single most misunderstood part of Gradle, so slow down here. Both `implementation` and `api` add a library to your module's compile classpath. The difference is what happens to **modules that depend on you**.

- `implementation` - the dependency is for your eyes only. Modules that depend on your module do **not** see it on their compile classpath.
- `api` - the dependency leaks through. Modules that depend on you **do** see it, as if they declared it themselves.

```kotlin
dependencies {
    // Guava is part of THIS module's public API - a method returns a Guava type.
    api("com.google.guava:guava:33.0.0-jre")

    // Jackson is an internal detail - used inside, never exposed in a signature.
    implementation("com.fasterxml.jackson.core:jackson-databind:2.16.1")
}
```

*What just happened:* You declared Guava as `api` because your public methods return Guava types - a consumer of your module needs Guava to even compile against you. Jackson you declared as `implementation` because it's a private detail; consumers should never know it exists. Get this wrong in the safe direction (using `implementation` when you meant `api`) and downstream code fails to compile. Get it wrong the other way (over-using `api`) and you bloat everyone's classpath and trigger needless recompiles when Jackson updates.

The rule of thumb: **default to `implementation`**. Only reach for `api` when a dependency's types appear in your module's public method signatures or return types. There's also `testImplementation` for dependencies only your tests need (like JUnit), and `runtimeOnly` for things needed at runtime but not at compile time (like a JDBC driver).

```kotlin
dependencies {
    implementation("org.slf4j:slf4j-api:2.0.12")       // compile + runtime, hidden from consumers
    runtimeOnly("org.postgresql:postgresql:42.7.1")    // runtime only, not on compile classpath
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")  // tests only
}
```

*What just happened:* Three different **configurations**, each putting its dependency on a different classpath at a different time. `slf4j-api` is needed to compile and run but hidden from consumers. The Postgres driver is loaded at runtime by reflection, so it never needs to be on the compile classpath. JUnit exists only for tests and never ships in your artifact. Choosing the tightest configuration that works keeps classpaths lean and builds fast.

## For builders

In a multi-module project - a `core` module, a `web` module, an `app` - getting `implementation` vs `api` right is what keeps your modules genuinely decoupled. If `core` declares everything as `api`, then `web` accidentally compiles against `core`'s internal libraries, and a year later you can't upgrade one of those libraries without touching three modules. Defaulting to `implementation` is how you keep the option to change `core`'s internals without a ripple effect.

```quiz
[
  {
    "q": "You add a library that is used only inside your module and never appears in a public method signature. Which configuration?",
    "choices": ["api", "implementation", "runtimeOnly", "compileOnly"],
    "answer": 1,
    "explain": "Default to implementation. It puts the library on your compile and runtime classpath but hides it from modules that depend on you."
  },
  {
    "q": "What does declaring a dependency as 'api' do that 'implementation' does not?",
    "choices": ["Downloads it faster", "Exposes it on the compile classpath of modules that depend on yours", "Makes it test-only", "Skips the version number"],
    "answer": 1,
    "explain": "api leaks the dependency through to your consumers' compile classpath. Use it only when the dependency's types appear in your public API."
  },
  {
    "q": "Where do tasks like compileJava and test come from in a standard build.gradle?",
    "choices": ["You must write them by hand", "They are built into the gradlew script", "An applied plugin (such as the java plugin) registers them", "They are downloaded from Maven Central"],
    "answer": 2,
    "explain": "Plugins contribute tasks and conventions. The java plugin alone registers compileJava, test, jar, build, and the standard source layout."
  }
]
```


---

# Why It's Fast, and Where It Bites

The first time you run a Gradle build it's slow - minutes, maybe. The second time, if you changed nothing, it finishes in under a second. That isn't magic and it isn't caching gone wrong. It's the core reason teams tolerate Gradle's complexity: it works hard to never redo work it has already done. This phase explains how that speed actually works, the one file that makes your builds reproducible everywhere, and the gotchas that turn a fast build slow.

## Up-to-date checks: the foundation of speed

Remember from Phase 1 that every task has inputs and outputs. Gradle uses that. Before running a task, it hashes the inputs and outputs and compares them to last time. If nothing changed, it skips the task entirely and marks it `UP-TO-DATE`.

```console
$ ./gradlew build        # first run
> Task :compileJava
> Task :test
BUILD SUCCESSFUL in 47s

$ ./gradlew build        # second run, no changes
> Task :compileJava UP-TO-DATE
> Task :test UP-TO-DATE
BUILD SUCCESSFUL in 0.8s
```

*What just happened:* On the second run, Gradle hashed each task's inputs (your source files, the dependencies, the task settings) and found them identical to the recorded outputs from last time. So it skipped the actual work and reported `UP-TO-DATE`. This is **incremental building**: only the tasks whose inputs actually changed get rerun. Change one `.java` file and only the tasks downstream of it rerun; the rest stay cached.

## The build cache: speed across machines and branches

Up-to-date checks help *one* checkout over time. The **build cache** goes further: it stores task outputs keyed by a hash of their inputs, so a result computed once can be reused by a *different* checkout, a *different* branch, or a *different* machine.

```
# gradle.properties - turn on the build cache for the project
org.gradle.caching=true
```

*What just happened:* With caching enabled, when Gradle is about to run a task it computes the input hash and checks the cache first. A hit means it pulls the prior output instead of recomputing. Switch to a teammate's branch and back, and the tasks you already built come straight from the local cache. Teams point this at a shared remote cache so CI builds and developer laptops reuse each other's compiled output - the first person to compile a given input pays the cost, everyone else gets it free.

> The mental model: up-to-date checks ask "did *I* already do this?" The build cache asks "did *anyone* already do this?" Same input hash, same output, no reason to recompute.

For a task to be cacheable, its inputs and outputs must be fully declared and it must be deterministic - same inputs always produce the same output. A task that reads the system clock or a random value breaks this, which is a classic cause of a build that "should be cached but never is."

## The wrapper: the file that makes "works on my machine" true

Open almost any Gradle project and you'll see `gradlew`, `gradlew.bat`, and a `gradle/wrapper/` folder. This is the **Gradle Wrapper**, and it solves a real pain: everyone needs the *same* Gradle version, or builds drift and break.

```
gradlew                              # Unix launcher script
gradlew.bat                          # Windows launcher script
gradle/wrapper/
├── gradle-wrapper.jar               # the bootstrap code
└── gradle-wrapper.properties        # pins the exact Gradle version
```

```
# gradle/wrapper/gradle-wrapper.properties
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
```

*What just happened:* The wrapper properties file pins the exact Gradle version this project uses. When you run `./gradlew build`, the wrapper script reads that URL, downloads that exact Gradle version if it isn't already present, and runs your build with it. Nobody has to install Gradle by hand, and everybody - your laptop, a new hire, the CI server - uses the identical version. This is why the rule is **always run `./gradlew`, never a globally installed `gradle`**. The wrapper files belong in version control; commit them.

To move the whole team to a new Gradle version, you change it in one place:

```console
$ ./gradlew wrapper --gradle-version 8.8
$ git add gradle/wrapper gradlew gradlew.bat
$ git commit -m "Bump Gradle wrapper to 8.8"
```

*What just happened:* The `wrapper` task rewrote the properties file (and refreshed the scripts) to point at 8.8. You commit the change, and the next time any teammate runs `./gradlew`, they transparently get 8.8. One commit migrates the entire team - no "please install Gradle X" message in the group chat.

## Where it bites: the real gotchas

Gradle's flexibility is also its sharpest edge. The same "it's all code" power that lets you do anything lets you do anything *wrong*.

**Configuration-phase work.** As covered in Phase 1, code in a bare task body runs on *every* build during configuration. Put an expensive operation there - a network call, a file scan - and every single build pays for it, even `./gradlew help`. Symptom: builds feel slow even when nothing changed. Fix: move real work into `doLast` or a proper task action.

**Cache misses from undeclared inputs.** If a task reads a file it didn't declare as an input, Gradle can't see the change and may wrongly report `UP-TO-DATE` - or, if the task is non-deterministic, never cache at all. Symptom: stale outputs, or a task that always reruns. Fix: declare every input and output accurately.

**Reaching for `clean` reflexively.** Coming from other tools, people run `./gradlew clean build` out of habit. But `clean` deletes the build directory, which throws away exactly the up-to-date state that makes Gradle fast - you've forced a full rebuild. Use `clean` only when you genuinely suspect corrupted output, not as a ritual.

```console
$ ./gradlew clean build     # almost always slower than you want
$ ./gradlew build           # let incremental builds do their job
```

*What just happened:* The first command nukes all cached task outputs and rebuilds from scratch, every time. The second lets Gradle skip the unchanged tasks. If your `clean build` is "to be safe," you're paying a tax on every build for a problem you probably don't have.

**Dependency version conflicts.** Two libraries pull in different versions of a third. Gradle picks the highest by default, which is usually right but occasionally surprises you. Symptom: a `NoSuchMethodError` at runtime. Fix: run `./gradlew dependencies` (from Phase 2) to see what was actually resolved, then pin or constrain the version if needed.

## In the wild

A team's build went from forty seconds to four after one change: someone had a JSON config being parsed in a bare `build.gradle` body, so it reparsed on every invocation of every task. Moving it into the task that needed it fixed the configuration-phase tax. The lesson generalizes - when a Gradle build is slow, suspect the configuration phase and undeclared inputs before you suspect Gradle itself. The graph (Phase 1) and `--dry-run` are still your best diagnostic tools. For shipping the artifacts you build, the broader release picture lives over at [/guides/build-and-release-basics](/guides/build-and-release-basics).

```quiz
[
  {
    "q": "What is the difference between Gradle's up-to-date checks and its build cache?",
    "choices": ["They are the same thing", "Up-to-date checks reuse work within one checkout over time; the build cache reuses work across branches and machines by input hash", "The build cache only works on CI", "Up-to-date checks require gradle.properties"],
    "answer": 1,
    "explain": "Up-to-date asks 'did I already do this in this checkout?' The build cache asks 'did anyone already do this?' and shares outputs across branches and machines."
  },
  {
    "q": "Why should you always run ./gradlew instead of a globally installed gradle?",
    "choices": ["It is shorter to type", "The wrapper pins and downloads the exact Gradle version the project expects, so everyone builds identically", "Global gradle is deprecated", "It enables the build cache automatically"],
    "answer": 1,
    "explain": "The wrapper reads gradle-wrapper.properties to use the exact version the project pins, so laptops, new hires, and CI all use the same Gradle."
  },
  {
    "q": "A teammate runs './gradlew clean build' on every change 'to be safe.' What is the cost?",
    "choices": ["No cost, it is best practice", "clean deletes the build directory, discarding the up-to-date state that makes incremental builds fast, forcing a full rebuild", "It corrupts the build cache", "It changes the Gradle version"],
    "answer": 1,
    "explain": "clean wipes cached task outputs, so every build starts from scratch. Use it only when you suspect corrupted output, not as a ritual."
  }
]
```
