Python Packaging: Why the Build Tool Downloads Java

2026/02/06

BUILDbazeldependenciespython

You add rules_python to your MODULE.bazel. You run bazel fetch //.... You go make coffee. You come back and Bazel is downloading the Java Development Kit.

You are building Python. You have never written a line of Java. You will never write a line of Java. And yet there it is — rules_java, rules_kotlin, rules_apple — calmly extracting into your external repos like they own the place.

This is the story of how a Python build rule pulls in three languages you didn’t ask for, and why Bazel can’t do anything about it.

The dependency tree nobody expects

Here’s the actual transitive closure of rules_python:

rules_python
├── bazel_skylib
├── rules_cc
├── protobuf  ← this is where it gets weird
│   ├── rules_java
│   ├── rules_kotlin
│   └── rules_apple (Swift!)
└── ...

rules_python ships py_proto_library — the rule for generating Python code from .proto files. Reasonable. Lots of Python projects use protobuf.

But py_proto_library lives inside the protobuf module, which also ships java_proto_library, cc_proto_library, kotlin_proto_library, and the rest. The protobuf module supports every language. So when you depend on protobuf, you get the toolchains for every language.

That’s the core of it. One transitive dependency on protobuf, and suddenly you’re fetching compilers for languages that will never execute on your machine.

Why Bzlmod can’t skip them

This is the part that stings. Bzlmod — Bazel’s modern dependency manager, the thing that replaced the hundred-line WORKSPACE file — has no concept of optional dependencies.

In npm, you have devDependencies and optionalDependencies. In Python, you have extras (pip install foo[bar]). In Bzlmod, you have… the full transitive closure. Every dependency of every dependency, recursively, no opt-out.

The resolution algorithm is MVS — Minimal Version Selection, borrowed from Go modules. It’s elegant for what it does: given a diamond dependency (A→C@1.0, B→C@2.0), pick the minimum version that satisfies all constraints (C@2.0). No SAT solver, no backtracking, deterministic.

But MVS doesn’t have a mechanism for “I only need the Python parts of protobuf, not the Java parts.” The module boundary is the module. You get all of it or none of it.

For comparison, here’s how the two systems stack up:

Aspect WORKSPACE (old) Bzlmod (MODULE.bazel)
Transitive deps You list ALL of them manually Automatic resolution
Version conflicts Manual resolution MVS algorithm
Repo names Flat (@foo) Namespaced (@foo+)
Boilerplate High (100+ lines) Low (5-10 lines)
Optional deps N/A (you just don’t list them) Not supported

WORKSPACE was like manually writing package-lock.json by hand. Painful, but you controlled exactly what showed up. Bzlmod is a proper package manager — less boilerplate, automatic resolution, but you trade granularity for convenience. The dependency closure is the dependency closure.

Official stance from Bazel: WORKSPACE is deprecated as of Bazel 7.0. So this is the world we live in now.

What actually gets fetched

I profiled a bazel fetch //... on our monorepo to see where the bytes actually go.

Repository cache (compressed tarballs): 17 MB External repos (extracted): 779 MB Fetch time: ~39 seconds

The big hitters:

Repository Size Why
gazelle_go_repository_cache 464 MB Gazelle is written in Go. This is Go’s module cache.
pypi_312_torch 381 MB PyTorch. No explanation needed.
rules_java ~30 MB You’re building Python.
rules_kotlin ~15 MB You’re still building Python.

The Java + Kotlin + Apple toolchains aren’t the biggest line items — torch dwarfs everything. But they’re the most surprising line items. 779 MB of external repos for a Python project, and a meaningful chunk of it is languages you’ll never use. On a cold CI runner with no cache, that’s real wall-clock time.

The Google exception

This whole problem is a Google-ism. Inside Google’s monorepo, everything is pre-fetched. The entire dependency graph exists in a single repository. There’s no “fetch” step — it’s all already there. Adding a transitive dependency on rules_java costs nothing because rules_java is already on disk, already built, already cached.

Outside Google, in the rest of the world where we have separate repositories and CI runners that start cold, transitive dependencies have a real cost. Every repo that gets fetched is network time. Every repo that gets extracted is disk I/O. Every toolchain that gets registered is Starlark evaluation time.

Bazel was designed for a world where the dependency graph is free. We don’t live in that world. So we pay the monorepo tax — the cost of carrying dependencies we don’t use because the module system can’t express “I only want part of this.”

Can you fix it?

Sort of.

Option 1: Override the module. You can use single_version_override or archive_override in your MODULE.bazel to point at a patched version of protobuf that strips the Java/Kotlin/Apple deps. This works but it’s maintenance burden — every upstream release, you re-patch.

Option 2: Cache aggressively. Repository cache persists across builds. Once the JDK downloads once, it’s a cache hit forever (until the version changes). On local dev machines, this makes the problem invisible after the first fetch. On CI, it depends on your cache setup — GitHub Actions caches the repo cache well, self-hosted runners vary.

Option 3: Wait for upstream. There’s been discussion in the Bazel community about splitting protobuf into per-language modules. protobuf_java, protobuf_python, etc. This would let rules_python depend on just protobuf_python, and the Java toolchain would never enter the picture. It’s the right fix. It hasn’t shipped.

Option 4: Accept it. The JDK downloads once. It sits in your repo cache. It never executes. It’s annoying in the same way that node_modules is annoying — philosophically offensive, practically ignorable.

I went with option 2. Cache the repo cache on CI, amortize the cold start across builds, move on. The 39-second fetch drops to under 5 seconds on a warm cache. The Java toolchain is still there, lurking in .cache/bazel, but it’s not costing me wall-clock time anymore.

The real lesson

Dependency managers make choices about granularity. npm chose per-package. Python chose per-distribution with optional extras. Bzlmod chose per-module with no optional parts.

Every choice has consequences. npm’s granularity gives you node_modules with 800 packages for a hello-world app. Python’s extras give you installation matrices that nobody tests. Bzlmod’s all-or-nothing gives you a JDK in your Python project.

The pattern is always the same: you trade simplicity in one dimension for surprise in another. Bzlmod’s dependency resolution is simple and deterministic. The surprise is that “simple and deterministic” means you get everything, whether you wanted it or not.

Your Python build tool downloads Java because it depends on protobuf, which supports Java, and the module system can’t express “but I don’t care about that part.” That’s it. That’s the whole thing. One missing feature in the dependency specification language, and now you have a JDK.

Welcome to build systems.