I spent a day trying to make uv.override_requirement() work for torch in Bazel. It did exactly what the name says — it overrode the requirement. Everywhere. Including the one place I needed it to not.
This is the story of how I fixed Linux by breaking macOS, felt smart three separate times, and ended up reverting everything.
Act 1: The Setup
Our monorepo trains multimodal LLMs. We use Bazel with aspect_rules_py and uv for Python dependency management. The torch situation is… special.
On macOS (developer laptops), torch installs normally via pip/uv — CPU-only, ~150MB, no drama. On Linux CI, torch is system-installed inside the Docker image (NVIDIA NGC base, CUDA, the whole party). Nobody pip-installs torch on Linux. The Docker image is the torch.
So pyproject.toml has this:
"torch>=2.7.0; sys_platform == 'darwin'"
Translation: “Dear uv, please resolve torch wheels for macOS. On Linux, don’t bother — it’s already here.”
This works perfectly for uv sync. It does not work perfectly for Bazel.
Nobody on the team had ever tried @pypi//torch in a Bazel target. 62% of our packages depend on torch. Someone had to find out what happens. That someone was me.
Act 2: The Smoke Test
I wrote a tiny test:
def test_torch_import():
import torch
assert torch.__version__ >= "2.7.0"
def test_torch_tensor():
import torch
x = torch.tensor([1.0, 2.0, 3.0])
assert x.sum().item() == 6.0
Two assertions. Import torch, do math. The absolute bare minimum to prove the dependency graph works.
$ bazel query @pypi//torch
@pypi//torch
Target exists. Good sign.
$ bazel test //bazel/tests:test_torch_smoke
//bazel/tests:test_torch_smoke PASSED in 3.2s
2/2 tests passed
Both tests pass on macOS. I open a PR. I feel smart.
Feeling smart is the setup for Act 3.
Act 3: CI Slaps You
Linux CI:
ERROR: configurable attribute "actual" in @pypi//torch doesn't match this configuration
Right. Of course. The uv.lock only has macOS wheels for torch (because of our sys_platform == 'darwin' gate). When Bazel generates the @pypi//torch target, the internal select() statement looks like:
select({
"@platforms//os:macos": "@pypi_torch_macos//:pkg",
# ...and nothing else
})
On Linux, no arm matches. Bazel doesn’t do “no match means skip” — it does “no match means die.”
Fine. Expected, if I’m being honest. The fix is straightforward:
py_test(
name = "test_torch_smoke",
srcs = ["test_torch_smoke.py"],
deps = ["@pypi//torch"],
target_compatible_with = ["@platforms//os:macos"],
)
target_compatible_with tells Bazel: “This target only makes sense on macOS. On Linux, skip it.” Clean. Declarative. Bazel-approved.
Push. Wait for CI.
CI finds two more things wrong.
Problem 2: verify_test_configs.py — our CI hygiene script — flags bazel/tests/ as an unconfigured test directory. It’s not in the allowlist. Add it. Fine.
Problem 3: rocket-gpu-tests fails in 3 seconds. Panic. Read the logs. Turns out someone else merged a regression that morning. My branch just happened to pick it up. Rebase onto a fix. Fine.
Three rounds of CI. All green. PR approved.
At this point the rational move is to merge and walk away. But I’d been reading the aspect_rules_py source code while waiting for CI, and I found something interesting.
Act 4: The Override (Hubris)
Deep in extension.bzl, there’s a function called _parse_overrides(). It reads a tag class called uv.override_requirement(). The mechanism is elegant: you point a pip package at a custom Bazel target, and the entire wheel download/install pipeline gets bypassed. Your target wins.
The plan formed in my head like a perfectly logical disaster:
“If I create an empty
py_libraryfor torch, and override@pypi//torchto point at it, then Linux won’t crash on the missing wheel. The empty shim satisfies the dependency graph. On macOS, the real wheel still loads because… because…”
I didn’t finish that thought. I was already writing code.
third_party/torch/BUILD.bazel
py_library(
name = "torch",
srcs = [],
visibility = ["//visibility:public"],
)
An empty target. A scarecrow. A py_library with no library in it.
# MODULE.bazel
uv.override_requirement(
hub_name = "pypi",
venv_name = "default",
requirement = "torch",
target = "//third_party/torch:torch",
)
$ bazel query @pypi//torch
//third_party/torch:torch
It resolves! The override works! I feel smart again!
I’m about to feel extremely not smart.
Act 5: The Punchline
$ bazel test //bazel/tests:test_torch_smoke
FAILED: ModuleNotFoundError: No module named 'torch'
On macOS. Where torch was working five minutes ago.
I stared at the screen. Read it again. Ran it again. Same result.
Then it hit me like a mass-produced epiphany:
override_requirement is global.
There’s no select(). There’s no platform condition. MODULE.bazel is evaluated once, at module extension time, before Bazel even thinks about what platform you’re on. The override replaced @pypi//torch with my empty scarecrow on every platform. macOS, Linux, that one guy running FreeBSD — all of them get the empty shim now.
I had fixed Linux by deleting torch from macOS.
The override that was supposed to make torch work everywhere… made it work nowhere.
$ git checkout -- MODULE.bazel third_party/
Revert. Walk away. Think about life choices.
Act 6: The Lesson
Here’s the mental model I should have had before writing a single line of code.
Bazel doesn’t evaluate your build in one pass. It does it in three, and they happen in a very specific order — and that order is the whole reason my override nuked everything.
Phase 1: MODULE.bazel — Setting Up the Universe
This is where module extensions run. uv.override_requirement() lives here. At this point, Bazel is resolving what packages exist. Think of it like running npm install — you’re deciding what goes into node_modules. You’re not building anything yet. You’re not running anything. You don’t even know what OS the code will eventually run on.
Platform? What platform? Bazel hasn’t gotten there yet. It’s still populating the shelves.
Phase 2: BUILD Files — Analysis and Configuration
Now Bazel knows things. It knows you’re on macOS arm64, or Linux x86_64, or that one guy’s FreeBSD box. This is where select() lives — the conditional logic that says “if macOS, use this dep; if Linux, use that one.” This is also where target_compatible_with does its thing, telling Bazel “don’t even bother with this target on that platform.”
All the platform-aware smarts live here. Phase 2 is where the grown-up decisions happen.
Phase 3: Execution — Actually Building
The part where code compiles and tests run. By now everything is decided. No more choices, just consequences.
Three Knobs, Three Layers
The reason I got wrecked is that I confused which knob operates at which layer. There are three mechanisms for dealing with “different platforms need different things,” and they are not interchangeable:
uv.override_requirement() runs at Phase 1. It’s like npm link — it replaces a package for everyone, unconditionally. There’s no “only on macOS” option. It rewires the dependency graph before Bazel even knows what a platform is.
select() runs at Phase 2. It’s the #ifdef __APPLE__ of Bazel — conditional compilation based on platform, config, whatever you want. This is where platform awareness actually works.
target_compatible_with also runs at Phase 2. It’s like putting [skip ci] on a test — it tells Bazel “this target doesn’t exist on that platform, don’t try to build it, don’t complain about it.”
My mistake was reaching for the Phase 1 knob to solve a Phase 2 problem. I was trying to conditionally configure a package before conditions existed.
The Chicken-and-Egg Problem
“Okay,” you might be thinking, “but what if you made the override target itself platform-aware? Put a select() inside the target that the override points to?”
I thought that too. Here’s what I wished would work:
# //third_party/torch:torch
alias(
name = "torch",
actual = select({
"@platforms//os:macos": "@pypi_original//torch", # ← use the real wheel
"@platforms//os:linux": ":empty_shim", # ← use nothing
}),
)
Looks clean, right? On macOS, select the real torch. On Linux, select the empty shim. Best of both worlds.
Except @pypi_original//torch doesn’t exist anymore. That’s what override_requirement does — it doesn’t just redirect; it prevents the original wheel repo from being created in the first place. The extension code in extension.bzl sees the override and skips the entire sdist → wheel → install pipeline for that package. The repository rule never fires. The external repo is never generated. There’s nothing to select back to.
It’s like replacing a bridge with a detour sign that says “bridge this way →” pointing to where the bridge used to be. You demolished the bridge to build the sign.
This is why there’s no clever select()-inside-the-override trick. The override runs at Phase 1, the select() evaluates at Phase 2, and by Phase 2 the thing you want to select has already been erased from existence. The chicken ate the egg and then asked where breakfast went.
So Now What
I’m shipping the boring version: macOS-only torch tests, no override. It works. It’s honest about what it can do.
But the problem isn’t solved — it’s deferred. 62% of our packages depend on torch, and on Linux they all fail resolution. The real options are:
Option A: Keep macOS-only. Accept that Bazel torch tests only run on developer laptops. CI tests torch through Docker the old-fashioned way. Ugly but true.
Option B: Platform-aware override target. Make //third_party/torch:torch itself a select() — real wheel on macOS, empty shim on Linux. But can the override target reference the original @pypi//torch wheel? You just overrode it. It’s turtles all the way down.
Option C: Remove the sys_platform gate entirely. Let uv resolve Linux CPU wheels too. No override needed — the select() in @pypi//torch would have matching arms for both platforms. Tradeoff: ~800MB of Linux torch wheels in uv.lock that nobody will ever use (because Linux CI uses the Docker-installed torch). Your lockfile gains weight. Your conscience takes a hit.
Option D: http_archive a CPU torch wheel. Download it as a Bazel external dep, wire it into the sandbox manually. Fully hermetic. Also fully manual. Welcome to writing Starlark for the rest of your life.
Each option trades one kind of pain for another. The correct answer probably depends on which pain you’re most willing to explain in a design doc.
The Takeaway
If I had to distill this into one sentence for the next person who finds uv.override_requirement() and thinks “I know exactly what to do with this”:
Override means override. Everywhere. Unconditionally. MODULE.bazel doesn’t know what platform you’re on, and it doesn’t care.
The word “override” should have been my first clue. Not “conditionally_replace.” Not “platform_aware_substitute.” Override. As in: the old thing is gone now. All of it. On every platform. Forever (until you revert the commit).
I learned this the hard way so you don’t have to. You’re welcome.