What Is a Configuration?
When Bazel analyzes a target during the Analysis phase, it does so in a specific configuration — the full set of build settings that affect compilation:
- Target platform (linux_x86_64, etc.)
- Compilation mode (opt, dbg, fastbuild)
- Custom build settings (feature flags, etc.)
The same target can be analyzed in multiple configurations. A library might be analyzed once for the target platform (to end up in the final binary) and once for the execution platform (if it’s also used as a build tool). Each configuration produces a separate analysis and potentially different compiled output.
The configuration is what makes select() work (see select()). When Bazel evaluates select({"@platforms//os:linux": [...], ...}), it checks the current configuration’s target platform.
The exec vs target Distinction
Some dependencies are the thing you’re building. Others are tools you use during the build. These need to be compiled for different machines.
Consider: you’re on a Mac (aarch64) cross-compiling a Rust binary for a Linux server (x86_64). The final binary must be compiled for Linux. But rustc itself needs to run on your Mac. And a code generator that runs during the build also needs to run on your Mac. Meanwhile, the library your binary links against needs to be compiled for Linux.
Bazel handles this by letting rule attributes specify which configuration their dependencies should be built in:
my_rule = rule(
attrs = {
# Built for the TARGET platform — the machine the final code runs on
# This is the default if you don't specify cfg
"deps": attr.label_list(),
# Built for the EXEC platform — the machine running the build
"_code_generator": attr.label(cfg = "exec", executable = True),
},
)Concrete PyO3 example: Rust proc-macros (#[pyfunction], #[pymodule], etc.) are compiler plugins. They run inside rustc during compilation — on the build machine, not the target machine. So proc-macros must be built for the execution platform. In rules_rust, this happens automatically: proc_macro_deps are built with cfg = "exec".
Transitions: Changing Configuration Across Edges
Sometimes exec vs target isn’t granular enough. You need to change a specific build setting when crossing a dependency edge. For example: you want the Rust compilation of a PyO3 module to use Python 3.11’s headers, even if the rest of the build defaults to Python 3.12.
Transitions let you modify the configuration as you traverse a dependency:
def _set_python_version_impl(settings, attr):
# Return a modified configuration: change the Python version
return {
"@rules_python//python/config:python_version": attr.python_version,
}
_set_python_version = transition(
implementation = _set_python_version_impl,
inputs = [],
outputs = ["@rules_python//python/config:python_version"],
)You attach a transition to an attribute or to the rule itself:
pyo3_extension = rule(
attrs = {
"python_version": attr.string(default = "3.11"),
# When analyzing deps, use the modified Python version:
"deps": attr.label_list(cfg = _set_python_version),
},
)Now when Bazel analyzes this rule’s dependencies, it applies the transition — changing the Python version in the configuration before analyzing them. The Rust compilation picks up the right Python headers.
You can also apply a transition to the rule itself (an incoming transition), changing the configuration the target is analyzed in, rather than the configuration of its deps.
Split Transitions: Building for Multiple Configurations
A split transition creates multiple configurations from a single dependency. This is how you build the same code for multiple platforms or versions at once:
def _multi_python_impl(settings, attr):
return {
"py311": {"@rules_python//python/config:python_version": "3.11"},
"py312": {"@rules_python//python/config:python_version": "3.12"},
}The dependency is now analyzed twice — once per configuration. In the implementation function, you get a dict of targets:
def _multi_wheel_impl(ctx):
# ctx.split_attr.module is {"py311": Target, "py312": Target}
for version_key, target in ctx.split_attr.module.items():
so_file = target[DefaultInfo].files.to_list()[0]
# ... package each .so into a wheel ...Warning
Every distinct configuration means separate analysis and compilation. 3 Python versions x 2 platforms = the Rust code compiled 6 times. Transitions are powerful but have a multiplicative cost.