The Problem Toolchains Solve
Every build rule needs a compiler. A Rust rule needs rustc, a Python rule needs a Python interpreter, a C++ rule needs gcc or clang. The question is: how does the rule find the right compiler?
The naive answers all fail at scale:
- Hardcode a path like
/usr/bin/rustc— breaks on machines where Rust is installed elsewhere, breaks in CI, breaks with remote execution - Make the user pass it as an attribute on every target — unmanageable when you have thousands of targets
- Put it in a global variable — can’t vary per platform, can’t handle cross-compilation (building on macOS for Linux)
Bazel’s solution separates three concerns that the naive approaches conflate:
-
The rule declares what kind of tool it needs: “I need a Rust compiler.” This is a toolchain type — just a label that acts as a type tag.
-
The toolchain definition packages a specific tool and says what platforms it works with: “Here is
rustc1.75, it runs on Linux x86_64 and produces code for Linux x86_64.” This combines the tool itself with platform constraints. -
The registration tells Bazel to consider this toolchain during resolution: “Add this to the list of available Rust toolchains.”
During Analysis, Bazel automatically matches a rule’s toolchain needs to the best available toolchain for the current platform. Rules never name a specific toolchain — they just say what type they need.
How Bazel Picks the Right Toolchain
When Bazel encounters a rule that says “I need @rules_rust//rust:toolchain_type”, it runs a simple algorithm:
- Look at the execution platform — the machine running the build (your laptop, CI, a remote worker)
- Look at the target platform — the machine the compiled code will run on (same as execution for native builds, different for cross-compilation)
- Walk through all registered toolchains of the requested type, in registration order
- For each, check: can it run on the execution platform? Does it produce code for the target platform?
- Return the first match
The rule implementation then accesses the resolved toolchain:
def _my_rule_impl(ctx):
rust_tc = ctx.toolchains["@rules_rust//rust:toolchain_type"]
rustc = rust_tc.rustc # a File object — the actual rustc binary, managed by BazelThe rustc here is a Bazel-managed artifact — downloaded, checksummed, and cached by Bazel. It’s the same binary on every machine. This is what makes remote execution possible: the remote worker doesn’t need Rust installed. Bazel ships the toolchain binary along with your source files.
What a Toolchain Looks Like Concretely
A toolchain definition has two parts. The tool bundle packages the actual binaries and libraries:
rust_toolchain(
name = "rust_linux_x86_64_impl",
rustc = "@rust_linux_x86_64//:rustc",
rust_std = "@rust_linux_x86_64//:std_libs",
target_triple = "x86_64-unknown-linux-gnu",
)The toolchain wrapper adds platform constraints and connects to the toolchain type:
toolchain(
name = "rust_linux_x86_64",
toolchain = ":rust_linux_x86_64_impl", # the tool bundle
toolchain_type = "@rules_rust//rust:toolchain_type", # what kind of tool
exec_compatible_with = [ # what machines can RUN it
"@platforms//os:linux",
"@platforms//cpu:x86_64",
],
target_compatible_with = [ # what machines it builds FOR
"@platforms//os:linux",
"@platforms//cpu:x86_64",
],
)Registration makes Bazel aware of it:
# In MODULE.bazel:
register_toolchains("@rules_rust//rust/toolchain:rust_linux_x86_64")When you write a custom rule, you declare what toolchain types it needs:
pyo3_extension = rule(
implementation = _pyo3_extension_impl,
toolchains = ["@rules_rust//rust:toolchain_type", "@rules_python//python:toolchain_type"],
attrs = { ... },
)Without this declaration, ctx.toolchains[...] would error — Bazel wouldn’t know to resolve those toolchains for your rule.
Platforms and Constraints
The Mental Model
Bazel needs a way to describe platforms (machines) so it can answer questions like “can this toolchain run on macOS?” and “does this target work on ARM?” It does this with a two-level abstraction:
Constraint settings are dimensions of variation — things that can differ between machines:
constraint_setting(name = "os") # "operating system" is a dimension
constraint_setting(name = "cpu") # "CPU architecture" is a dimensionConstraint values are the specific options within a dimension:
constraint_value(name = "linux", constraint_setting = ":os")
constraint_value(name = "macos", constraint_setting = ":os")
constraint_value(name = "x86_64", constraint_setting = ":cpu")
constraint_value(name = "aarch64", constraint_setting = ":cpu")A platform is a concrete machine description — a set of constraint values:
platform(
name = "linux_x86_64",
constraint_values = ["@platforms//os:linux", "@platforms//cpu:x86_64"],
)Bazel provides the common ones (@platforms//os:linux, @platforms//cpu:x86_64, etc.) out of the box. You can define custom constraints for your organization (e.g., GPU type, CUDA version).
Execution Platform vs Target Platform
These are distinct concepts that matter for toolchain resolution:
- Execution platform: where build tools (
rustc,gcc) actually run. Your workstation, CI, or a remote worker. - Target platform: where the compiled code will run.
For native builds, they’re the same. For cross-compilation they differ: you’re on macOS aarch64 (execution) building a binary that will run on Linux x86_64 (target). Bazel selects a toolchain that runs on the execution platform and produces code for the target platform.
select(): Deferring Decisions to Analysis
During Loading, the platform isn’t known yet (Loading just reads BUILD files). But you often need platform-specific behavior — different deps on Linux vs macOS, for example.
select() solves this. It’s a value that Bazel resolves during Analysis, when the platform IS known:
rust_shared_library(
name = "my_module_rs",
deps = ["@crate_index//:pyo3"] + select({
"@platforms//os:linux": ["@crate_index//:nix"], # Linux-only dep
"@platforms//os:macos": ["@crate_index//:core-foundation"],
"//conditions:default": [],
}),
)During Loading, select() is an opaque value. During Analysis, Bazel checks the current platform and picks the matching branch. This is the correct way to handle platform-specific logic — not macros that try to inspect the environment.
Marking Targets as Platform-Specific
You can declare that a target only makes sense on certain platforms:
rust_shared_library(
name = "my_module_rs",
target_compatible_with = ["@platforms//os:linux"],
)If someone tries to build this for macOS, Bazel skips it (in wildcard builds like //...) or errors (if explicitly requested).