target_compatible_with vs select() — When to Skip a Target vs When to Adapt

2026/03/02

BUILDbazel🍞

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:

  1. Different rules. py_test on macOS, py_venv_test on Linux. Can’t select() that.
  2. 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.
  3. 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.