TL;DR: Bazel gives you two tools for platform-specific behavior: target_compatible_with says “this target doesn’t exist here,” and select() says “this target exists everywhere but behaves differently.” The distinction matters more than you’d think, and picking the wrong one leads to either phantom test failures or unnecessarily duplicated targets.
This came up during our torch migration. The short version: torch installs from @pypi//torch on macOS but lives in system site-packages on Linux (via Docker). We needed different test strategies per platform – not just different deps.
The Two Mechanisms
target_compatible_with — “This Target Doesn’t Exist Here”
This is a constraint declaration. You’re telling Bazel: don’t even try to build this target on platforms that don’t match.
# macOS only — torch comes from @pypi
py_test(
name = "test_torch_smoke",
srcs = ["test_torch_smoke.py"],
target_compatible_with = ["@platforms//os:macos"],
deps = ["@pypi//torch"],
)
# Linux only — torch comes from system site-packages in Docker
py_venv_test(
name = "test_torch_smoke_system",
srcs = ["test_torch_smoke.py"],
include_system_site_packages = True,
tags = ["torch", "manual"],
target_compatible_with = ["@platforms//os:linux"],
deps = ["@pypi//pytest"],
)
When you run bazel test //... on macOS, the Linux target is silently skipped. Not failed, not errored – skipped. It’s as if it doesn’t exist. The inverse happens on Linux. Clean.
select() — “This Target Exists Everywhere, With Variations”
This is a configurable attribute. The target always exists, but some of its properties change based on platform.
py_test(
name = "test_torch_smoke",
srcs = ["test_torch_smoke.py"],
deps = select({
"@platforms//os:macos": ["@pypi//torch"],
"@platforms//os:linux": ["@pypi//pytest"],
}),
)
One target, one name, builds on both platforms. Bazel picks the right branch at analysis time. The target is always visible in queries, always participates in //... patterns.
When to Use Which
Here’s the decision tree I actually use:
| Situation | Use | Why |
|---|---|---|
| Different deps per platform, same rule | select() |
One target is cleaner than two |
Different rule per platform (e.g., py_test vs py_venv_test) |
target_compatible_with + separate targets |
You can’t select() which rule to use |
| Target is meaningless on some platforms | target_compatible_with |
Don’t pollute the build graph |
| Target must always run, just with platform tweaks | select() |
Ensures it’s never accidentally skipped |
| Platform-specific native bindings in a library | select() |
Same API, different implementation |
The key insight: select() operates on attributes within a single rule invocation. target_compatible_with operates on whether the rule invocation should exist at all.
You can select() deps, srcs, copts, data – anything that’s a configurable attribute. You cannot select() the rule itself. If your macOS test needs py_test and your Linux test needs py_venv_test with include_system_site_packages = True, there is no way to express that as a single target. You need two targets, each constrained to its platform.
The CI Angle
This is where the choice has real consequences.
target_compatible_with plays beautifully with bazel test //.... Incompatible targets are skipped with a clear message:
//my_package:test_torch_smoke_system SKIPPED (incompatible)
No failure, no noise. Your macOS CI sees macOS tests. Your Linux CI sees Linux tests. No per-platform --test_tag_filters, no exclude patterns, no manual target lists.
select() means the target always runs. If your select() branches are correct, great. If they’re not – say you forgot a platform or the default case is wrong – you get a build error or, worse, a test that silently does the wrong thing. The upside is that you’ll never accidentally skip a test that should have run.
The tradeoff: target_compatible_with is safer for “this test is irrelevant on platform X” but dangerous if you accidentally mark something compatible with the wrong platform. select() is safer for “this test must run everywhere” but requires you to handle every platform in the select dict.
What We Actually Did
For the torch smoke tests, we used target_compatible_with with two separate targets. The reasons:
- Different rules.
py_teston macOS,py_venv_teston Linux. Can’tselect()that. - Different dependency strategies. macOS pulls torch from
@pypi//. Linux uses system site-packages. These aren’t just different dep lists – they’re fundamentally different package resolution mechanisms. - Clean CI.
bazel test //...does the right thing on both platforms without any filtering flags.
If we’d needed the same test rule with just a different dep list, select() would’ve been the right call. But our situation was more like “two different test approaches that happen to verify the same thing” – and that maps to two targets, not one target with a branch.
Quick Reference
Do I need different rules per platform?
├── Yes → target_compatible_with + separate targets
└── No → Do I need different attributes per platform?
├── Yes → select()
└── No → Just write a normal target, you're overthinking it
The Bazel docs describe target_compatible_with under “platform constraints” and select() under “configurable attributes.” They’re in different sections for a reason – they solve different problems. The confusion comes from the fact that both involve @platforms// labels, but one is asking “should this target exist?” and the other is asking “how should this target behave?”
Once that distinction clicks, the choice is usually obvious.