Bazel is Google's build system, open-sourced from its internal Blaze, and it makes one promise that Maven and Gradle do not make by default: given the same inputs, a build step produces the same outputs, so its result can be cached and reused anywhere. Everything distinctive about Bazel follows from taking that promise seriously. You declare every dependency explicitly, build steps run in sandboxes that can only see what they declared, and outputs are keyed by a hash of their inputs, so a laptop, a CI runner and a colleague's machine can share one cache.

This page explains how Bazel models a build, sets up a small Java project the way Bazel 9 expects, walks through what happens when you change a file, and covers caching, querying the graph, the failure modes teams hit in their first months, and when Bazel is worth its cost compared with Gradle or Maven.

Advertisement

How Bazel models a build

Bazel's vocabulary is small and precise. A module is a project with a MODULE.bazel file at its root; it declares the module's name and its external dependencies. A package is any directory containing a BUILD.bazel file, and it owns the files beneath it down to the next package. A target is something declared in a BUILD file, usually by calling a rule such as java_library. A label names a target: //core:core is the target core in the package core of the main repository, and @maven//:com_google_guava_guava is a target in the external repository maven.

A build runs in three phases. Loading reads the BUILD files the requested targets need and evaluates their Starlark, a deterministic Python dialect. Analysis runs each rule's implementation, which turns targets into actions: concrete commands with declared inputs and outputs, such as one javac invocation. Execution runs only the actions whose outputs are needed and not already available. The action graph, not the target list, is what Bazel caches and parallelises.

Each action's cache key is a digest of its command line, its environment and the content of every input file. If the key matches a previous result, locally or in a remote cache, Bazel downloads or reuses the output instead of running the action. This is why undeclared inputs are fatal to Bazel's model: an action that secretly reads a file the key does not include can return a stale cached result.

MODULE.bazel + BUILDdeclared targetsLoading + analysistargets to actionsAction graphkey = hash(cmd, env, inputs)Cache lookuplocal, disk, remoteExecute misssandbox, worker, remote execOutputsjars, test resultsmissupload + usehit: reuse outputOnly actions whose keys changed run; everything else comes from a cache.
Bazel turns declared targets into actions keyed by their inputs, looks each key up in the caches, and runs only the misses.

What changed in Bazel 9

Bazel 9.0, released in January 2026 as a long-term support release, finished two migrations that change how a new project looks. External dependencies are managed only by Bzlmod through MODULE.bazel; the old WORKSPACE mechanism, disabled by default in Bazel 8, has been removed. And language rules are no longer built in: they ship as separate modules, and Bazel 9 no longer loads them automatically, so every BUILD file loads java_library and friends from rules_java explicitly. Many tutorials written before 2025 show WORKSPACE files and unloaded Java rules; both fail on Bazel 9.

Pin the Bazel version itself with a .bazelversion file and run Bazel through Bazelisk, a launcher that downloads the pinned version, so every developer and CI job uses the same binary.

Advertisement

Worked example: a small Java service

Here is a small service: a core library, a service binary that uses it and Guava, and a test. Start with the module file. The versions shown were the newest on the Bazel Central Registry when this was written; check registry.bazel.build for current ones.

# MODULE.bazel
module(name = "greeter", version = "0.1.0")

bazel_dep(name = "rules_java", version = "9.9.0")
bazel_dep(name = "rules_jvm_external", version = "7.1")

maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")
maven.install(
    artifacts = [
        "com.google.guava:guava:33.4.8-jre",
        "junit:junit:4.13.2",
    ],
    repositories = ["https://repo1.maven.org/maven2"],
    lock_file = "//:maven_install.json",
)
use_repo(maven, "maven")

Create an empty maven_install.json and a root BUILD.bazel, then run REPIN=1 bazel run @maven//:pin to resolve the Maven graph and write the lock file. Commit it; from then on builds use the pinned, checksummed artifacts and never resolve versions on the fly. Maven coordinates become versionless labels with punctuation replaced by underscores.

# core/BUILD.bazel
load("@rules_java//java:defs.bzl", "java_library", "java_test")

java_library(
    name = "core",
    srcs = glob(["src/main/java/**/*.java"]),
    visibility = ["//service:__pkg__"],
)

java_test(
    name = "GreeterTest",
    srcs = ["src/test/java/com/example/core/GreeterTest.java"],
    test_class = "com.example.core.GreeterTest",
    deps = [":core", "@maven//:junit_junit"],
)

# service/BUILD.bazel
load("@rules_java//java:defs.bzl", "java_binary")

java_binary(
    name = "app",
    srcs = glob(["src/main/java/**/*.java"]),
    main_class = "com.example.service.Main",
    deps = ["//core", "@maven//:com_google_guava_guava"],
)
bazel build //...            # everything
bazel test //core:all        # run the tests in one package
bazel run //service:app      # build and run the binary
bazel build //service:app_deploy.jar   # single fat jar, an implicit output of java_binary

The visibility attribute is worth noticing. It lets core say that only the service package may depend on it, which turns architectural rules that are usually wiki pages into build errors.

How Java builds work inside Bazel

Three Java-specific mechanisms explain most of Bazel's speed and most of its first-week friction.

Strict dependencies. A target may use only types from targets it lists in deps, not from their transitive dependencies. If service uses a Guava class but only depends on core, which happens to depend on Guava, compilation fails and the error tells you which label to add. It feels pedantic at first, but it means removing a dependency from core can never silently break service.

Header jars. For each library Bazel also produces an interface jar containing only signatures. Dependents compile against that, so a change to a method body in core changes the full jar but not the header jar, the dependents' compile action keys stay the same, and they are not recompiled. Only a change to the public API ripples outward.

Persistent workers. Starting a JVM per javac action would be slow, so Bazel keeps warm compiler processes, called workers, alive between actions and sends them requests. That keeps JIT-warmed javac and lets incremental builds feel close to an IDE.

The JDK is a toolchain, not whatever happens to be on PATH. The --java_language_version flag sets the source and target level and --java_runtime_version the runtime used to run binaries and tests; by default rules_java supplies a downloaded remote JDK, which keeps builds hermetic across machines.

Tracing one change through the build

Walk through an edit to make the model concrete. You change the body of a method in core/src/main/java/com/example/core/Greeter.java and run bazel test //....

  1. Bazel's server, still running from the last build, notices the changed file by its digest. Nothing needs reloading because no BUILD file changed.
  2. The compile action for //core has a new key because one input changed, so it runs on a warm worker.
  3. The new header jar is byte-for-byte identical to the old one, because no signature changed, so the compile actions for //service:app keep their keys and are cache hits.
  4. GreeterTest depends on the full core jar, so its test action has a new key and runs. Tests that do not depend on core are reported as cached and never run.

In a large repository, this is the difference between rebuilding and retesting the project and rebuilding and retesting the few targets an edit can affect. The same reasoning lets CI run bazel test //... on every change and only pay for what the change touched, provided it shares a cache.

Local and remote caching

Locally, Bazel keeps an output base per workspace. Add a disk cache to share results across branches and clean checkouts, and a remote cache to share them across machines. The remote cache speaks a standard gRPC protocol, so several open-source and commercial servers implement it, and the same protocol extends to remote execution, where actions run on a farm of workers instead of the local machine.

# .bazelrc
build --disk_cache=~/.cache/bazel-disk
build --incompatible_strict_action_env      # fixed PATH; host env does not leak into keys
build:ci --remote_cache=grpcs://cache.example.internal
build:ci --remote_upload_local_results=true
build --remote_download_outputs=toplevel    # fetch only what you asked for
test --test_output=errors

Treat the remote cache as part of your supply chain. Only trusted CI should upload; developer machines should usually read only, because an action run on a misconfigured laptop can otherwise publish a bad result under a valid key.

Querying the graph

Because the graph is explicit, Bazel can answer questions that are guesswork elsewhere. bazel query works on the target graph before configuration, bazel cquery after flags and platforms are applied, and bazel aquery on the action graph, showing exact command lines.

bazel query 'rdeps(//..., //core)'                     # what breaks if core changes
bazel query 'somepath(//service:app, @maven//:com_google_guava_guava)'
bazel cquery 'deps(//service:app)' --output=files      # resolved outputs for this config
bazel aquery 'mnemonic("Javac", //core)'               # the actual javac command

The reverse-dependency query is the basis of test selection in large repositories, and the action query is the first tool to reach for when two machines disagree about a build.

Failure modes

  • Non-hermetic tests. Tests that hit the network, read the clock or depend on execution order pass or fail randomly, and a cached flaky pass hides the problem. Tag them, isolate them, or fix them; Bazel can rerun flaky tests but should not be used to conceal them.
  • Leaking environment. Without strict action environment, a different PATH or tool version on one machine changes behaviour without changing keys, and caches serve inconsistent results.
  • Version conflicts. rules_jvm_external resolves one version of each artifact for the whole repository. That is a feature for monorepos, but two libraries that need incompatible versions of the same dependency must be resolved deliberately, sometimes with separate named Maven repositories.
  • Broad globs and giant targets. One java_library globbing a whole module recompiles everything on any edit; finer-grained targets give better caching at the cost of more BUILD maintenance.
  • Stale tutorials. Copying WORKSPACE examples or unloaded rule calls into a Bazel 9 project fails immediately; follow current rules_java and Bzlmod documentation.

Bazel, Gradle and Maven compared

ConcernBazelGradleMaven
Caching modelper action, content-addressed, remote by designtask outputs, local and remote build cachemostly none beyond the local repository
Dependency declarationexplicit per target, strictper project or source setper module POM
Polyglot buildsfirst-class: Java, C++, Go, Python, protobuf in one graphpossible with pluginsJava-focused
Ecosystem and IDE supportsmaller; needs dedicated pluginslargelargest
Adoption costhigh: BUILD files, rules, cache infrastructuremoderatelow

Bazel pays off when builds and test suites are large, several languages share one repository, or CI time is dominated by redoing unchanged work. For a single-language service of modest size, Gradle's build cache or plain Maven gives most of the benefit with far less migration effort.

If you do migrate, do it incrementally rather than in one cut-over. Start by listing every external artifact the existing build resolves and moving them into one maven.install with a committed lock file, because version disagreements surface there first. Convert leaf modules with no internal dependencies next, one java_library per existing module, and run both builds side by side in CI until their test results agree. Only then split large modules into finer targets for better caching, guided by which targets change most often. Code generation, annotation processors and resource filtering usually take the most effort, since each must become a declared action with explicit inputs instead of a plugin step that reads whatever it likes.

What to do next

  1. Install Bazelisk, add a .bazelversion, and build a throwaway module with the MODULE.bazel and BUILD files above.
  2. Pin your Maven artifacts with REPIN=1 bazel run @maven//:pin and commit the lock file.
  3. Break one strict-deps rule on purpose to see the error, then fix it by adding the label it names.
  4. Change a method body, run bazel test //... with --explain=explain.log, and confirm from the log which actions ran and why.
  5. Add a disk cache, then trial a read-only remote cache in CI before allowing uploads.
  6. Use bazel query 'rdeps(...)' to estimate how many targets a typical change affects in your codebase, and decide from that whether a migration is worth it.

Compare with the other Java build tools in Gradle, in depth and Apache Maven, in depth, and read JUnit 5 (Jupiter), in depth and Java Modules, in depth for the testing and modularity topics a Bazel migration touches.

Key takeaway: Bazel builds an explicit graph of targets, turns it into actions keyed by a hash of their inputs, and runs only actions whose keys have no cached result, locally or remotely. In Bazel 9 dependencies come only from MODULE.bazel via Bzlmod and Java rules must be loaded from rules_java. Strict deps, header jars and persistent workers make Java builds incremental and predictable. Keep actions hermetic, let only CI write to the remote cache, and adopt Bazel where build scale or a polyglot repository justifies its cost.