A couple of articles ago, I touched on Bazel transitions. In that context, I was referring to 1:1 transitions—transitions that change the single configuration of a target. Split transitions, on the other hand, are 1:N, meaning they build the same target in multiple configurations.

Multi-arch build

An obvious example where split transitions are useful is multi-platform (or multi-architecture) builds. Because my background is in Swift and Apple platforms, I immediately think of device and simulator builds.

You can think of a split transition as turning a single dependency edge:

A → B

into multiple ones:

A → B (device)
  → B (simulator)

In other words, a single dependency is “split” into multiple configurations.

Multi-platform Swift library

Let’s imagine we want to build a swift_library for both iOS device and simulator—for example, to validate both environments in CI or to produce multi-platform artifacts.

Note: In real-world Apple builds, you would typically rely on existing transition support provided by rules_apple rather than defining your own. This example is intentionally simplified to illustrate how split transitions work under the hood.

To achieve this, we need to define a transition and a wrapper rule.

def _split_transition_impl(_settings, _attr):
    return {
        "device": {"//command_line_option:platforms": "@apple_support//platforms:ios_arm64"},
        "sim": {"//command_line_option:platforms": "@apple_support//platforms:ios_sim_arm64"},
    }

split_transition = transition(
    implementation = _split_transition_impl,
    inputs = [],
    outputs = ["//command_line_option:platforms"],
)

This illustrates why they’re called split transitions: a single dependency edge is expanded into multiple configurations, each identified by a key (device, sim).

Next, we define a rule that applies the transition:

def _multi_platform_swift_library(ctx):
    propagated_files = []

    for split_deps in ctx.split_attr.deps.values():
        for dep in split_deps:
            propagated_files.append(dep[DefaultInfo].files)

    return [
        DefaultInfo(
            files = depset(transitive = propagated_files),
        ),
    ]

multi_platform_swift_library = rule(
    implementation = _multi_platform_swift_library,
    attrs = {
        "deps": attr.label_list(cfg = split_transition),
    },
)

Here, ctx.split_attr.deps is a dictionary where each key (device, sim) maps to the list of dependencies built in that configuration.

We simply propagate the files from the dependencies so that they are built—this keeps the example minimal while still demonstrating the transition.

Finally, in BUILD.bazel, we define targets using these rules:

load("@rules_swift//swift:swift_library.bzl", "swift_library")
load("//:rules.bzl", "multi_platform_swift_library")

swift_library(
    name = "lib",
    module_name = "Library",
    srcs = ["main.swift"],
)

multi_platform_swift_library(
    name = "mp",
    deps = [":lib"],
)

To verify that the transition has been applied, we can use Bazel’s configuration query:

bazel cquery //:mp --transitions=full

This will produce output similar to:

INFO: Analyzed target //:mp (0 packages loaded, 0 targets configured).
INFO: Found 1 target...
NoTransition -> //:mp (680dcaf)
  deps#//:lib#(Starlark transition:/Users/adincebic/developer.noindex/split/rules.bzl:8:30 + (TestTrimmingTransition + ConfigFeatureFlagTaggedTrimmingTransition)) -> 58f99a6, c9f1a2b
    platforms:[@@bazel_tools//tools:host_platform] -> [[@@apple_support+//platforms:ios_arm64], [@@apple_support+//platforms:ios_sim_arm64]]
  $allowlist_function_transition#@bazel_tools//tools/allowlists/function_transition_allowlist:function_transition_allowlist#(null transition) -> 
INFO: Elapsed time: 0.067s, Critical Path: 0.00s
INFO: 0 processes.
INFO: Build completed successfully, 0 total actions

Notice how //:lib is configured twice—once for each platform (ios_arm64 and ios_sim_arm64). This confirms that the split transition produced multiple configurations.

Conclusion

Bazel transitions are a powerful tool, but they should be used sparingly. They fundamentally alter the build graph, and split transitions in particular can significantly increase its size since dependencies may be built multiple times.

Conceptually, however, split transitions are no more complex than standard 1:1 transitions—they simply require using split_attr instead of attr, and thinking in terms of one dependency becoming many.