4.7.3 Split Transitions
recommendedA split transition forks one dependency edge into several configured copies in a single build. The 1:1 transitions from 4.7.2 Starlark Transitions change a subtree's configuration. A split transition gives the parent rule more than one configured version of the same dependency, each built under different build settings, and lets the parent combine them into a single output.1 The canonical use is multi-architecture artifacts — macOS universal binaries, Android APKs with native code for several CPUs, fat container images — where one final target must aggregate per-arch builds that the user wrote only once.2
A 1:N Transition Returns A Collection Of Dicts
A normal 1:1 transition implementation returns a single dict of {setting: value}. A 1:N split transition returns multiple dicts in one of two shapes. The cleanest is a dict of dicts, where each branch carries an explicit string key the parent rule uses later to look the configured dep back up:1
def _universal_binary_transition_impl(settings, attr):
return {
"x86_64": {"//command_line_option:cpu": "darwin_x86_64"},
"arm64": {"//command_line_option:cpu": "darwin_arm64"},
}
universal_binary_transition = transition(
implementation = _universal_binary_transition_impl,
inputs = [],
outputs = ["//command_line_option:cpu"],
)
The other form is a list of dicts — one configuration per element with no explicit branch names. Bazel still indexes the resulting deps in ctx.split_attr, but the keys are positional strings ("0", "1", ...), not the values written to the transition's outputs. Use the dict-of-dicts form when the parent rule wants stable, human-chosen branch names. Use the list form when the parent can just iterate over ctx.split_attr.<attr>.values().
In both shapes the contract from 4.7.2 Starlark Transitions still holds: every non-empty returned inner dict's key set must exactly match the transition's declared outputs, and the implementation may only read settings declared in inputs.3
Split Transitions Are Outgoing-Only
A split transition cannot run on rule(cfg = ...). Incoming-edge transitions must be 1:1, because a configured target can only have one configuration.1 A split would have to re-instantiate the parent in several configurations at once, which is not how the build graph is shaped.
The legal attachment point is an outgoing dependency edge — the cfg parameter of attr.label or attr.label_list:
universal_binary = rule(
implementation = _impl,
attrs = {
"binary": attr.label(
cfg = universal_binary_transition,
mandatory = True,
),
},
)
The mental model is "a single parent target, multiple configured copies of the dep." The parent stays in its own configuration. Only the subtree under the transitioned attribute is duplicated. Older transition examples still carry a _allowlist_function_transition attribute on every transition-using rule. The requirement was dropped in Bazel 7.1, and the :::extra block in 4.7.2 Starlark Transitions explains why new rules omit it.
Reading The Branches With ctx.split_attr
Inside the parent rule's implementation, ctx.attr.dep becomes a flat list containing every configured copy of the split dep, with no guaranteed order.4 The structured access path is ctx.split_attr, which exposes the same deps as a dict keyed by the split transition's branch keys: the outer keys from the dict-of-dicts return value, not the output setting values.4,5 The split-transition-keys snippet proves this by returning branch keys branch_alpha / branch_beta while writing build-setting values x86 / arm64.
def _rule_impl(ctx):
x86_64_dep = ctx.split_attr.dep["x86_64"]
arm64_dep = ctx.split_attr.dep["arm64"]
# ctx.attr.dep is a list of all branches; order is unspecified.
all_deps = ctx.attr.dep
The branch outputs are ordinary configured targets, so the parent reads DefaultInfo, custom providers, runfiles — anything from 4.2 Custom Rules, Providers & Actions — through the normal provider API, then returns a single combined output:
def _universal_binary_impl(ctx):
arm64_bin = ctx.split_attr.binary["arm64"][DefaultInfo].files.to_list()[0]
x86_64_bin = ctx.split_attr.binary["x86_64"][DefaultInfo].files.to_list()[0]
universal = ctx.actions.declare_file(ctx.label.name)
ctx.actions.run(
executable = "/usr/bin/lipo",
arguments = ["-create", "-output", universal.path,
arm64_bin.path, x86_64_bin.path],
inputs = [arm64_bin, x86_64_bin],
outputs = [universal],
)
return [DefaultInfo(files = depset([universal]))]
That is the entire universal-binary pattern: one rule, one action that calls lipo, and a transition that configures the dependency tree once per architecture.2
Multi-Architecture Is The Use Case, Not Multi-Setting
Split transitions are about producing aggregate artifacts that span configurations, not about toggling features. Concrete examples that fit the shape:
- macOS universal binaries — one
lipoaction overdarwin_x86_64anddarwin_arm64builds of the samecc_binary.2 - Multi-arch container images — a manifest list packaging per-arch image layers built from the same source tree.
rules_ocidemonstrates this exact fan-out: its transition returns one--platformsconfiguration per requested platform, then an image index combines the configured images.6 - Android APKs with native libraries — packaging a per-ABI
.sofor each native library next to one Java/Kotlin payload.7 - Cross-platform release packages — a single zip containing per-OS or per-CPU executables built from the same target graph.
The shared shape is "one output target, several per-configuration inputs the parent must combine." A split transition is the graph mechanism that lets one rule see all those configured inputs through ctx.split_attr instead of asking the user to declare N parallel targets and stitch them together by hand.
When the goal is "build this dep with a different flag" — --compilation_mode, an instrumentation toggle, a project build setting — that is the 1:1 case from 4.7.2 Starlark Transitions, not a split. When the goal is "make a tool dep build for the host", that is the built-in cfg = "exec" from 4.6.4 Execution Configuration for Tools (cfg = "exec"). Reach for a split transition only when the parent rule legitimately needs several configured outputs of the same dep at once.
Cost Model: Splits Multiply The Configured Graph
Every branch of a split transition duplicates the entire transitioned subtree as configured targets, because each configuration is its own slice of the build graph from 2.1 Directed Acyclic Graph (DAG). Two-way split, ten-way split, nested splits — they all compose multiplicatively. The cost compounds in the worst case: a one-flag-per-edge transition over a binary tree of depth n produces 2^n configured copies of the leaf target, with matching memory and analysis-time cost.1
For real multi-arch artifacts the cost is acceptable because the underlying work — compiling the same source for several CPUs — is genuine. The trap is using splits as an ergonomic shortcut for project-wide feature switching, where the same effect is reachable with a build setting from 4.7.1 Build Settings for Rule Authors and a single-shot 1:1 transition or select(). The follow-up considerations — how transitions spread through unintended attribute edges, how identical configurations end up in distinct output directories, and how to dedupe them — live in 4.7.4 Transition Boundaries & Gotchas, which directly extends this article's cost discussion.
Reach for a split transition when one rule must aggregate the same dep built under several configurations into one output: universal binaries, multi-arch APKs and containers, multi-OS release packages.
The shape is fixed: outgoing-edge attachment via attr.label(cfg = ...), an implementation that returns a list or dict of {setting: value} dicts, and a parent that reads each branch through ctx.split_attr. Everything below the transitioned edge gets reanalyzed once per branch, so use it for real per-configuration work, not as a project-wide feature switch.
Check your understanding · 4 questions
1.A team wants every test under //tests/asan to build with -fsanitize=address while the rest of the project builds normally. Is a split transition the right tool?
Select one answer
2.True or false about how split transitions attach and report results:
Choose True or False for each sentence
rule(cfg = my_split) to give the rule itself several configurations.ctx.attr.dep becomes a flat list of every configured copy of the split dep, with no guaranteed order.ctx.split_attr.dep["key"] reads the dep configured for that branch.3.A universal_binary rule's transition returns [{"//command_line_option:cpu": "darwin_x86_64"}, {"//command_line_option:cpu": "darwin_arm64"}] (list-of-dicts form). What keys does ctx.split_attr.binary use?
Select one answer
4.A split transition returns a dict-of-dicts with outer keys "arm64" and "x86_64". How should the parent rule keep the two configured deps attached to the right output?
Select one answer
Footnotes
-
Configurations — 1:2+ transition mechanics: list-of-dicts vs dict-of-dicts return forms, outgoing-edge attachment, custom branch keys, the rule that incoming-edge transitions must be 1:1, and the exponential-growth case study for transition cost. ↩1 ↩2 ↩3 ↩4
-
Utilizing Bazel for Cross-Platform and Cross-Architecture Compilation and Testing — Salesforce's macOS universal-binary rule: a transition returning per-
cpuconfigurations, thelipoaction that combines results, andctx.split_attraccess from the parent implementation. Current Bazel uses positional keys for list-returning split transitions. Use dict-returning transitions for explicit branch names. ↩1 ↩2 ↩3 -
transition —
transition()API: requiredinputs/outputs, the(settings, attr)implementation contract, and the rule that returned dicts may use a list or dict of dicts for split transitions. ↩ -
ctx —
ctx.split_attrsemantics: dict-of-key-to-ConfiguredTarget for label attrs, list-merged form remains visible throughctx.attr. ↩1 ↩2 -
Configurations —
ctx.split_attr.dep["key"]access pattern when the transition uses custom branch keys. ↩ -
rules_oci repository map — the runnable
multi_architecture_imageexample pairs its focusedtransition.bzlwith OCI image-index assembly. ↩ -
Glossary — split transitions illustrated by an Android APK with ARM and x86 native binaries built in a single Bazel invocation. ↩