# bazel-diff: working out exactly which Bazel targets a commit broke

> bazel-diff is a Rust command line tool from Tinder that compares two Git revisions of a Bazel project and reports the exact set of impacted targets, so a CI job can build and test only what changed. It suits a large monorepo where guessing the affected tests is either slow or unreliable, and it streams Bazel's query output to stay within memory limits.

**Tinder/bazel-diff** — Performs Bazel Target Diffing between two revisions in Git, allowing for Test Target Selection and Selective Building

- Repository: https://github.com/Tinder/bazel-diff
- Stars: 532 · Forks: 95
- Language: Rust
- License: NOASSERTION
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/tinder-bazel-diff

## The problem, and why an approximation is worse than a slow answer

bazel-diff is a command line tool for Bazel projects that allows users to determine the exact affected set of impacted targets between two Git revisions, and then to test or build the exact modified set of targets.

The design decision is the word exact, and the documentation makes the argument for it in a quotation that is worth reading in full. There are simpler and faster ways to approximate the affected set of targets. However, an incorrect solution can result in a system you can't trust, because tests could be broken at a commit where you didn't select to run them. Then you can't rely on green-to-red, or red-to-green, transitions, and you lose much of the value from your CI system as breakages can be discovered later on unrelated commits.

That is the argument for a whole class of tooling, and it is a good one. The common approach to test selection is a heuristic: a file changed under a directory means the tests under that directory are affected, and if a shared library changed then everything is affected. The heuristic is fast and it is wrong in both directions. It runs tests that were not affected, which is only a waste of time, and it misses tests that were affected, which is a broken continuous integration signal, and the second failure is the one that matters, because a pipeline that never goes red stops being evidence of anything.

The approach was inspired by a BazelConf talk by Benjamin Peterson, and the feature request for target diffing in Bazel itself is tracked upstream in an issue on the Bazel repository. So this is a workaround for a capability Bazel does not have, written by a company with a large enough Bazel project to need it.

The licence situation has a wrinkle worth noting. The licence recorded for the repository is a custom one that GitHub cannot classify, with the terms in the LICENSE file, while the Cargo manifest in the same tree declares the package as Apache-2.0. Both cannot be the whole answer, and the LICENSE file is the one to read.

## What it does that a home-grown diff does not

The feature list in the readme names three advantages over rolling your own target diffing solution, and the second of them is the most instructive.

The first is scale. bazel-diff is designed for very large Bazel projects, and it streams Bazel Query's streamed_proto output message by message instead of loading it whole, which allows it to parse gigabyte or larger protobuf outputs. The project says it has been tested with projects containing hundreds of thousands of targets. A query result for a project that size is not a JSON document you can hold in memory, so the streaming is what makes the whole approach viable at that scale rather than an optimisation.

The second is the one that catches people out. It avoids using large command line query lists when interacting with Bazel, and the linked Bazel issue is the one about the argument list limit. When you interact with Bazel with thousands of query parameters you can reach an upper maximum limit and see the error bash: /usr/local/bin/bazel: Argument list too long. A naive implementation that shells out to bazel query with one expression per target hits the shell's limit and fails on exactly the large projects the tool is meant to serve.

The third is correctness across change types. bazel-diff has been tested with file renames, deletions and modifications, and it works on bzl files, WORKSPACE files, BUILD files and regular files. The rename case is the one that catches naive implementations, because a rename changes the path of a file and therefore the label of every target built from it, and a diff that treats a rename as a delete plus an add gets the affected set wrong in a way that is easy to miss.

The prerequisites are short: Git, and Bazel 8 or higher, with the note to use the latest release for your major version. And one detail that saves an install: Bazel needs a JDK, the bazel-diff binary does not.

## Running it, and the two-step model

The quick start is to clone the repository and run the example shell script:

```terminal
git clone https://github.com/Tinder/bazel-diff.git
cd bazel-diff
./bazel-diff-example.sh WORKSPACE_PATH BAZEL_PATH START_GIT_REVISION END_GIT_REVISION
```

The four arguments are the path to the directory containing your WORKSPACE file, the path to your Bazel executable, and the starting and final Git branch or SHA for the commit range you care about. The script is described as purely an example use-case but a great starting point, and there is a PowerShell version of it in the repository for Windows users.

The underlying model is three steps, and they are worth understanding because they explain the cost.

The previous revision is checked out, then generate-hashes is run, which gives the hashmap representation for the entire Bazel graph, and that JSON is written to a file. Next the initial revision is checked out, generate-hashes is run again, and the second JSON is written. Then bazel-diff is run on the starting and final JSON hash file paths to get the impacted set of targets, which is written to a file.

So the tool is a comparison of two snapshots of the build graph, not a diff of source files. The hashmap captures the state of every target, including the attributes of its rule and the files it depends on, and a target is impacted when its hash changed or when something it depends on changed.

The cost follows from that: two full graph analyses and two checkouts before the answer. In exchange, the answer is exact, and it is a reusable artefact, since the JSON files can be kept and the comparison rerun without touching the workspace.

There is also a third-party integration worth knowing about. Aspect's Extension Language, AXL, allows the example shell script to be expressed in Starlark and exposed as an impacted command on your terminal, in a separate project called impacted.

## Distance metrics, and what they let you do with the impacted set

bazel-diff can optionally compute build graph distance metrics between two revisions, and this is the feature that turns a list of labels into something a CI policy can be built on.

The metrics are two. target_distance is the number of dependency hops it takes to get from an impacted target to a directly impacted target. package_distance is the number of dependency hops that cross a package boundary on the way. Directly impacted targets are those whose rule attributes or source file dependencies changed, and indirectly impacted targets are those affected only through one of their target dependencies.

The distinction between the two metrics is the useful part. A target one hop away is a leaf of the change, and a target ten hops away across five package boundaries is a long way from anything that changed. The documentation lists three uses that follow directly: only running sanitizers on impacted tests that are in the same package as a directly impacted target, only running large-sized tests that are within a few package hops of a directly impacted target, and only running computationally expensive jobs when an impacted target is within a certain distance of a directly impacted target.

That is a scheduling policy built on graph distance, and it is a much better use of a large test suite than a directory prefix heuristic, because it accounts for the actual dependency structure of the build.

To use it you have to generate a dependency mapping on your final revision when computing hashes, and pass it into the get-impacted-targets command:

```bash
git checkout BASE_REV
bazel-diff generate-hashes -w /path/to/workspace -b bazel starting_hashes.json

git checkout FINAL_REV
bazel-diff generate-hashes -w /path/to/workspace -b bazel --depEdgesFile deps.json final_hashes.json

bazel-diff get-impacted-targets -w /path/to/workspace -b bazel -sh starting_hashes.json -fh final_hashes.json --depEdgesFile deps.json -o impacted_targets.json
```

The output is a JSON list with the label and the two distances:

```text
[
  {"label": "//foo:bar", "targetDistance": 0, "packageDistance": 0},
  {"label": "//foo:baz", "targetDistance": 1, "packageDistance": 0},
  {"label": "//bar:qux", "targetDistance": 1, "packageDistance": 1}
]
```

A target distance of zero means the target is directly impacted, and a package distance of one means the path to it crosses exactly one package boundary, which is how the example distinguishes a dependency inside the same package from one across a package line.

## Explaining why a target was impacted, and the project behind the tool

The most recent addition to the tool is the one that answers the question a CI engineer actually asks.

get-impacted-targets tells you that //service/a:app needs rebuilding. It does not tell you why, and a service can be impacted for several reasons at once: it changed itself, a shared module it depends on changed, or both. The explain command answers that by attributing the target's hash change to the upstream target or targets actually responsible, and by showing the dependency path the change travelled along. The behaviour is tracked in an issue in the repository.

The design constraint on explain is what makes it useful in practice. It reads the same three files that get-impacted-targets consumes, it runs no bazel query and needs no workspace, so it is cheap to run after the fact on a CI machine that only kept the artefacts:

```bash
bazel-diff explain \
  -sh starting_hashes.json \
  -fh final_hashes.json \
  -d deps.json \
  --target //service/a:app
```

That matters because the usual moment to ask why a target was impacted is after a build has already failed, at which point the workspace is often gone and the only surviving things are the JSON files the previous step produced.

The project itself is a good example of dogfooding. The repository is a Bazel project that builds itself with Bazel, with a .bazelrc, a .bazelversion, a MODULE.bazel and a WORKSPACE.bzlmod, and a Makefile whose targets are Bazel invocations. The build target is a plain bazel build of the root package in the opt configuration, there are release targets for the Rust binary including statically linked musl cross-compiles from Linux or Apple Silicon macOS, and there is a format target that runs a pinned rustfmt through Bazel.

The Rust code itself is a small, well-chosen dependency set. The manifest declares Rust 1.85 and edition 2021, uses anyhow for errors and clap with derive and env for the command line, rayon for parallelism, sha2 and hex for hashing, serde for the JSON, flate2 and tempfile for file handling, and url and walkdir for path work. Two dependencies are pinned to an exact version with the equals sign, attohttpc and rust-s3, which is the convention when a library has to keep working with a specific transitive dependency, and the build has a vendored protoc option so the protobuf toolchain does not have to be installed on the machine.

The release profile is thin LTO with symbols stripped, which is the standard choice for a binary that people download, and the package is marked as not for publication, because the releases are the built binaries rather than a crate from a registry.

## Conclusion

bazel-diff is worth adopting in a large Bazel monorepo, because the alternative is a heuristic that silently selects the wrong tests, and a heuristic that misses a broken test is worse than a slow one, since a missed test turns the whole green-to-red signal of continuous integration into noise. It is the wrong tool if your project builds in seconds, and the wrong tool if you do not use Bazel. Before you rely on it, check three things: that your Bazel is version 8 or higher and that you have Git, since Bazel itself needs a JDK while the bazel-diff binary does not; that you understand the cost model, because the tool checks out both revisions and runs generate-hashes on each, which is two full graph analyses, so it is a pre-step that costs more than a single query but saves the repeated cost of building and testing everything downstream; and that you check the licence file, because the licence recorded for the repository is a custom one while the package manifest in the same tree says Apache-2.0, which is a discrepancy worth resolving before you vendor it.

## FAQ

### What does bazel-diff compute and how do I run it?

It computes the exact set of impacted Bazel targets between two Git revisions. The quick start is to clone the repository and run ./bazel-diff-example.sh with the path to your WORKSPACE directory, the path to your Bazel executable and the starting and final Git revisions, and the prerequisites are Git and Bazel 8 or higher, with a JDK needed for Bazel but not for the bazel-diff binary.

### What are the build graph distance metrics in bazel-diff?

They are optional metrics computed between two revisions. target_distance is the number of dependency hops from an impacted target to a directly impacted target, and package_distance is the number of those hops that cross a package boundary. They require a dependency mapping generated on the final revision with the depEdgesFile option and passed to get-impacted-targets, and they let a pipeline run only the expensive tests that are close enough to the change to matter.

### Why does the bazel-diff documentation insist on an exact answer rather than an approximation?

Because an incorrect solution produces a system you cannot trust, since tests could be broken at a commit where you did not select to run them, and then you cannot rely on green-to-red or red-to-green transitions and lose much of the value of the CI system as breakages are discovered later on unrelated commits. The tool also streams Bazel Query's streamed_proto output message by message so gigabyte-scale query results can be parsed, and it avoids large command line query lists that hit the shell argument length limit.

## Sources

- [Issues](https://github.com/Tinder/bazel-diff/issues)
- [README](https://github.com/Tinder/bazel-diff/blob/master/README.md)
- [Releases](https://github.com/Tinder/bazel-diff/releases)
- [Tinder/bazel-diff on GitHub](https://github.com/Tinder/bazel-diff)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/tinder-bazel-diff
