3.4.5 Managing unused_deps

Extra deps on a target are not free. Every declared edge widens the set of actions an input change can invalidate, expands the per-action input set so cache hit rates drop and invalidation broadens, and makes the target graph harder to reason about — exactly the failure mode catalogued from the diagnostic side in 2.1.4 Common Dependency Issues. The way a maintainer keeps that drift under control is by pairing strict enforcement on the "missing deps" side with periodic pruning on the "unused deps" side, and letting tooling drive the edits instead of hand-audit.

At Stripe, engineers were waiting for Bazel the equivalent of 150 engineer-hours per day, and the investigation traced the cost back to BUILD files that had drifted — a single new payment service had accumulated 1,400 Java library dependencies because people added dependencies until the code compiled and moved on.1 That is the failure the unused_deps workflow is designed to correct.

What unused_deps actually does

The canonical tool is unused_deps from bazelbuild/buildtools, the same repo that ships buildifier and buildozer. It is a Go binary, it takes a space-separated list of Bazel labels (with :all and ... target-pattern support), it is scoped to java_library rules, and it prints buildozer commands for the pruning it suggests rather than editing BUILD files in place.2 Upstream documents go install github.com/bazelbuild/buildtools/unused_deps@latest as a convenient evaluation path. A shared repository or CI job should replace latest with a reviewed buildtools tag or commit and expose that pinned binary through 3.5.3 Developer Tool Management. Applying the results is therefore a versioned, reviewable diff, not a blind rewrite.

The buildtools repository map keeps the public unused_deps workflow next to the buildozer command contract, which is the shortest upstream route for checking both halves of that emit-then-review workflow.

unused_deps //... > prune.sh
# review prune.sh
bash prune.sh

The reason the tool is Java-specific is the same reason it gives trustworthy answers when it does work: it piggybacks on the Java compiler. The three-stage pattern is the clearest form of the idea — a Bazel graph pulled with bazel query --output proto, a Java source graph collected by a javac plugin that records imported and exported symbols, and a comparison step where edges present in the Bazel graph but absent from the source graph are flagged as unused.1 That compile-time anchor is what separates a dependency that looks unused from one the compiler never resolved a symbol through.

unused_deps finds declared Java deps with no compile-time use
The set difference is the model. The pipeline gathers the two sets that it compares.
declared deps
from BUILD targets
compiler-observed deps
symbols javac resolved
=
removal candidates
review before applying
1 · Read declarations
Collect each target's declared deps
Bazel graph → declared set
2 · Observe Java compile
Record jars javac resolved symbols from
Java-specific plugin / .jdeps
3 · Subtract and emit
Print removal commands for the difference
buildozer commands → stdout
The middle stage sees only compile-time use. Generated code, annotation processors such as Dagger, reflection, and runtime-only edges can therefore be false positives — review the emitted commands.

Why a clean-looking tool gets noisy in practice

The reason unused_deps surprises people is that "used at compile time" and "declared in deps" are not the same set. Generated code, annotation processors like Dagger, and reflective runtime-only dependencies all show up as unused from a compile-time view even though removing them breaks the build at a later stage — run the upstream tool against such code and it produces a flood of errors, after which Bazel immediately suggests re-adding many of the dependencies it just deleted.1

Macros around Java targets make the problem worse. A wrapper macro layered on top of java_export can break unused_deps and buildozer edits together, because the automated tooling can no longer see the real underlying rule it is meant to rewrite.3 This is the practical cost of abstraction layers on top of the native Java rules: the more the BUILD shape diverges from plain java_library, the less the upstream hygiene tooling applies.

Treat it as the other half of strict deps

unused_deps only makes sense when paired with --strict_java_deps. The flag controls whether javac refuses compilation when a source file references a type from a jar that is not part of the current target's direct dependencies. The accepted values are off, warn, error, strict, and the default (which behaves like strict).4 With it on, Bazel emits a [strict] error that names the missing target and suggests a shell command — in practice a buildozer 'add deps …' line — that automatically inserts the missing direct dependency.

Strict deps and unused_deps bracket the same hygiene problem from opposite sides. Strict deps stops you from relying on a transitive edge you never declared. unused_deps removes declared edges you no longer rely on. Strict transitive dependencies were rolled out across Google's codebase explicitly so that engineers could remove deps without fear of breaking distant targets, and pruning is the natural companion to that mode.5

On earlier Bazel versions, maintainers already treated unused_deps as an on-demand "remove unused deps → apply" step run before push rather than a blocking lint, because the compile-time-only view is too noisy to gate every edit on.6 The guidance has aged well: run it at specific moments, not every CI build.

The pattern beyond Java

The same "Bazel graph vs source graph, emit buildozer" shape recurs across languages, just with different analysers.7

  • Python. rules_fawltydeps runs FawltyDeps as an aspect (see 4.8 Aspects) over py_library / PyInfo providers, compares declared deps with the imports it observes in source, and emits buildozer commands for fixes — with --strict_java_deps-like suggestions for missing packages and cached results because the analysis is executed through Bazel itself.8 Language-specific setup belongs in L3 Python.
  • JVM ecosystems. bazel-deps is the JVM equivalent, and the broader classpath governance story — duplicate classes, unwanted transitives — sits under L1 Java & Kotlin (JVM).
  • C++ and similar. Tools like depend_on_what_you_use and bazel_iwyu fill the same role for C/C++ by scanning headers, and they belong to the same state-of-the-art set as FawltyDeps and bazel-deps for keeping deps honest.7

None of these are fully safe to automate. Dep-metadata freshness generally cannot be automated end to end, because edge cases and occasional false positives are unavoidable.7 That is why every one of these tools emits buildozer commands for a human to review instead of editing BUILD files in place.

A workflow that survives the false positives

A few working patterns show up repeatedly in practice:

  • Scope to targets you understand. Do not run unused_deps repo-wide unless you can judge which services carry runtime-only dependencies. A workable rollout publishes the tool internally and lets teams opt in. Where teams actually run it, reductions of up to 35 % in runtime deps are achievable.1
  • Make the diff reviewable. The tool's output is a shell script of buildozer commands. Keep that separation: run the tool, inspect the diff, apply only the commands you trust, commit the rest for follow-up. buildozer is covered in more detail in 3.4.3 Buildozer. The build-maintenance app/BUILD.bazel keeps a deliberately stale //unused:unused_helper edge so the buildozer plan helper can emit the matching remove deps line — the same emit-then-review shape the real unused_deps tool produces, even though the example does not run unused_deps itself. The buildozer-command-file snippet narrows that pattern to one generated command file so the review step is easy to inspect.
  • Pair with --strict_java_deps rather than relying on it alone. Strict deps alone will eventually accumulate deps entries that have outlived their imports. Periodic unused_deps passes keep that list honest.4,5
  • Respect runtime and reflective edges. When a genuinely runtime-only dep shows up as "unused", the fix is documentation or a test that exercises it, not a blanket removal. This is exactly why hygiene belongs on-demand before push rather than in an interactive lint.6
  • Do not confuse it with Gazelle. 3.4.2 Gazelle (BUILD File Generation) generates deps from source imports for supported languages. unused_deps prunes the deps list after humans or generators have written them. They complement each other, but Gazelle in file mode already enforces a narrower deps surface for the languages it supports, so there is less for unused_deps to remove on Gazelle-managed packages.

At repo scale, trimming unused edges is not just a tidiness concern — large transitive closures are a measurable driver of rebuild volume and CI cost, and the graph analysis tooling in 5.3 Graph Analysis is the natural place to quantify the benefit once this workflow is routine.7

extra

Kotlin, Android, and the aspect hand-off

The compile-time anchor extends beyond java_library in principle — anything that drives a Java / Kotlin / Android compile through the standard toolchain produces the symbol metadata the comparison step needs.1 In practice the upstream tool restricts its scope to java_library, and custom wrapper macros that replace the native rule shape can hide otherwise eligible targets.3 For the general Bazel mechanism that makes this kind of build-time analysis reusable across tooling, see 4.8 Aspects.

key takeaway

Treat unused_deps pruning as the mirror image of --strict_java_deps: one stops drift on the "added too many" side, the other cleans up drift on the "never removed" side. Run it on targets you own, review the emitted buildozer commands as a diff, preserve runtime-only and reflective edges by hand, and expect equivalent but language-specific tools (FawltyDeps for Python, depend_on_what_you_use for C++, bazel-deps for JVM classpath shaping) to fill the same role everywhere else.1,4,7,8

Check your understanding · 3 questions

1.unused_deps reports a Java dependency as unused, but removing it breaks the build because Dagger uses it for annotation processing. What does this illustrate?

Select one answer

2.True or false about unused_deps and strict deps:

Choose True or False for each sentence

unused_deps and --strict_java_deps solve the same problem from the same direction.
unused_deps is scoped specifically to java_library rules in the upstream bazelbuild/buildtools tool.
FawltyDeps, depend_on_what_you_use, and bazel-deps fill the same unused-dep role for Python, C++, and JVM classpath respectively.

3.A team wants to use unused_deps safely across a large Java monorepo. What is the most responsible rollout strategy?

Select one answer

0 of 3 answered

Footnotes

  1. How Stripe's new tool detects extraneous Java dependencies — the 150 engineer-hours/day figure, the failure modes of Google's unused_deps on generated code and Dagger, and the three-stage Bazel-graph / source-graph / comparison architecture with opt-in rollout. 1 2 3 4 5 6

  2. unused_deps — bazelbuild/buildtools — installation via go install, CLI shape (unused_deps TARGET... with :all / ... support), and scope limited to java_library rules with buildozer-command output. The aspect / .javac_params / .jdeps pipeline implementation is the upstream tool's public code.

  3. Bazel for Everyone: Confluent — wrapper macros over java_library breaking unused_deps and buildozer automation. 1 2

  4. Commands and Options — canonical semantics for --strict_java_deps (off | warn | error | strict | default). The accompanying Dependency Management basics page describes the mode as "fails with an error and a shell command that can be used to automatically insert the dependency". 1 2 3

  5. Dependency Management — Google's multi-year rollout of strict transitive dependencies so engineers could safely remove unused deps. 1 2

  6. Make local development (with Bazel) great again! — Wix framing unused-deps pruning as an on-demand pre-push step, with strict deps and auto-dep handling the "missing direct deps" side. 1 2

  7. Managing dependency graph in a large codebase — stale / extra deps as a hygiene problem, cross-language tooling survey (FawltyDeps, depend_on_what_you_use, bazel-deps), and the caveat that automation cannot be fully safe. 1 2 3 4 5

  8. Using FawltyDeps: when Gazelle does not tame your Python — aspect-based unused/missing dep detection for Python, emitting buildozer commands, and the opt-in-per-target design rationale. 1 2