4.7.3 Split Transitions

recommended

A 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 split transition forks one dep edge into two configured copies
The parent rule reads each branch through ctx.split_attr and combines them with one declared action.
PARENT RULE
universal_binary
one split-attr dep edge
universal_binary( name = "fat_bin", binary = "//app:server", )
binary cfg = universal_binary_transition outputs //command_line_option:cpu
BRANCH "x86_64"
cc_binary //app:server
configured copy of the dep
--cpu = darwin_x86_64
ctx.split_attr.binary["x86_64"]
BRANCH "arm64"
cc_binary //app:server
configured copy of the dep
--cpu = darwin_arm64
ctx.split_attr.binary["arm64"]
merge action ctx.actions.run( inputs = branch_files, outputs = [fat_bin], executable = ctx.executable._merger, )
COMBINED OUTPUT
//app:fat_bin
single multi-arch binary
fat_bin contains arm64 + x86_64 slices
One universal_binary rule, two configured copies of the same dep, one combined output.

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 lipo action over darwin_x86_64 and darwin_arm64 builds of the same cc_binary.2
  • Multi-arch container images — a manifest list packaging per-arch image layers built from the same source tree. rules_oci demonstrates this exact fan-out: its transition returns one --platforms configuration per requested platform, then an image index combines the configured images.6
  • Android APKs with native libraries — packaging a per-ABI .so for 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.

key takeaway

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

A split transition can be attached as rule(cfg = my_split) to give the rule itself several configurations.
Inside the parent rule, ctx.attr.dep becomes a flat list of every configured copy of the split dep, with no guaranteed order.
When the transition returns a dict-of-dicts, ctx.split_attr.dep["key"] reads the dep configured for that branch.
The dependency subtree is duplicated at execution time, not analysis time.

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

0 of 4 answered

Footnotes

  1. 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

  2. Utilizing Bazel for Cross-Platform and Cross-Architecture Compilation and Testing — Salesforce's macOS universal-binary rule: a transition returning per-cpu configurations, the lipo action that combines results, and ctx.split_attr access 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

  3. transitiontransition() API: required inputs/outputs, the (settings, attr) implementation contract, and the rule that returned dicts may use a list or dict of dicts for split transitions.

  4. ctxctx.split_attr semantics: dict-of-key-to-ConfiguredTarget for label attrs, list-merged form remains visible through ctx.attr. 1 2

  5. Configurationsctx.split_attr.dep["key"] access pattern when the transition uses custom branch keys.

  6. rules_oci repository map — the runnable multi_architecture_image example pairs its focused transition.bzl with OCI image-index assembly.

  7. Glossary — split transitions illustrated by an Android APK with ARM and x86 native binaries built in a single Bazel invocation.