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.
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_fawltydepsruns FawltyDeps as an aspect (see 4.8 Aspects) overpy_library/PyInfoproviders, compares declareddepswith the imports it observes in source, and emitsbuildozercommands 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-depsis 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_useandbazel_iwyufill the same role for C/C++ by scanning headers, and they belong to the same state-of-the-art set as FawltyDeps andbazel-depsfor keepingdepshonest.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_depsrepo-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
buildozercommands. Keep that separation: run the tool, inspect the diff, apply only the commands you trust, commit the rest for follow-up.buildozeris covered in more detail in 3.4.3 Buildozer. The build-maintenanceapp/BUILD.bazelkeeps a deliberately stale//unused:unused_helperedge so the buildozer plan helper can emit the matchingremove depsline — the same emit-then-review shape the realunused_depstool produces, even though the example does not rununused_depsitself. 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_depsrather than relying on it alone. Strict deps alone will eventually accumulatedepsentries that have outlived their imports. Periodicunused_depspasses 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
depsfrom source imports for supported languages.unused_depsprunes the deps list after humans or generators have written them. They complement each other, but Gazelle in file mode already enforces a narrowerdepssurface for the languages it supports, so there is less forunused_depsto 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
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.
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
3.A team wants to use unused_deps safely across a large Java monorepo. What is the most responsible rollout strategy?
Select one answer
Footnotes
-
How Stripe's new tool detects extraneous Java dependencies — the 150 engineer-hours/day figure, the failure modes of Google's
unused_depson generated code and Dagger, and the three-stage Bazel-graph / source-graph / comparison architecture with opt-in rollout. ↩1 ↩2 ↩3 ↩4 ↩5 ↩6 -
unused_deps — bazelbuild/buildtools — installation via
go install, CLI shape (unused_deps TARGET...with:all/...support), and scope limited tojava_libraryrules with buildozer-command output. The aspect /.javac_params/.jdepspipeline implementation is the upstream tool's public code. ↩ -
Bazel for Everyone: Confluent — wrapper macros over
java_librarybreakingunused_depsand buildozer automation. ↩1 ↩2 -
Commands and Options — canonical semantics for
--strict_java_deps(off | warn | error | strict | default). The accompanyingDependency Managementbasics 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 -
Dependency Management — Google's multi-year rollout of strict transitive dependencies so engineers could safely remove unused deps. ↩1 ↩2
-
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
-
Managing dependency graph in a large codebase — stale / extra
depsas 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 -
Using FawltyDeps: when Gazelle does not tame your Python — aspect-based unused/missing dep detection for Python, emitting
buildozercommands, and the opt-in-per-target design rationale. ↩1 ↩2