4.7.2 Starlark Transitions
3.3.1 Configurable Attributes (select()) reads the build configuration to choose between attribute values. 4.7.1 Build Settings for Rule Authors defines new typed entries that can sit in that configuration. A Starlark transition is the third piece: a function that modifies configuration as Bazel walks an edge of the dependency graph, so the subtree below the edge is analyzed under different flag values than the parent.1 This is the mechanism a rule author reaches for when "compile this dependency for a different CPU than its parent" or "build this test with sanitizer flags but leave the rest of the project alone" must be expressed inside the build graph rather than as a global flag.
A Bazel build is just configured targets — (target, configuration) pairs.2 Transitions are how rules introduce new configurations into that pair space without the user typing a different bazel build command for each one. The configuration story so far in 3.2 Project Configuration covered who sets flags (.bazelrc, the command line, named configs), and 4.6.1 Platform Model for Rule Authors sharpened the target-vs-execution distinction that rule authors actually program against. This article covers how rule code changes the configuration mid-graph, between a rule and one of its dependency subtrees.
Anatomy of a Transition
A transition is declared with the transition() builtin and an implementation function:1,3
def _address_sanitizer_impl(settings, attr):
sanitizer_flags = ["-fsanitize=address"]
return {
"//command_line_option:copt": settings["//command_line_option:copt"] + sanitizer_flags,
"//command_line_option:linkopt": settings["//command_line_option:linkopt"] + sanitizer_flags,
}
address_sanitizer_transition = transition(
implementation = _address_sanitizer_impl,
inputs = [
"//command_line_option:copt",
"//command_line_option:linkopt",
],
outputs = [
"//command_line_option:copt",
"//command_line_option:linkopt",
],
)
This example enables AddressSanitizer, a compiler and runtime instrumentation mode for detecting memory errors, only for the transitioned subtree.
Three things make this declaration a contract Bazel can analyze ahead of time:3
inputslists every build setting the implementation may read. Bazel populates thesettingsdict with exactly those keys. Reading any other key is an error.outputslists every build setting the implementation may write. The returned dict's keys must match this list exactly — even settings the transition is "passing through" unchanged must appear in the returned dict.1implementationreceivessettings(a{label: value}dict) andattr(a reference to the attaching rule'sctx.attr).3 Returning a single dict is a 1:1 transition. Returning a list or dict of dicts is a 1:N split transition (covered in 4.7.3 Split Transitions).
If the implementation returns {}, [], or None, the transition is a no-op — every output stays at its incoming value. This is convenient when a transition only conditionally changes settings:1
def _debug_mode_impl(settings, attr):
if settings["//config:build_mode"] == "debug":
return {}
return {"//config:build_mode": "debug"}
Where Transitions Attach
A transition is just a function. What makes it run is attaching it to a rule or attribute. Bazel exposes two attachment points, and they behave differently.1
Incoming-edge transition — applied to the rule itself via rule(cfg = ...). The rule re-enters its own analysis under the new configuration. Incoming-edge transitions must be 1:1 (a rule cannot simultaneously become two configured targets):1
debug_binary_rule = rule(
implementation = _debug_binary_impl,
cfg = debug_transition,
attrs = {...},
)
Outgoing-edge transition — applied to a dependency attribute via attr.label(cfg = ...) or attr.label_list(cfg = ...). The parent keeps its configuration. The dep subtree under that attribute is built under the new one. Outgoing-edge transitions can be 1:1 or 1:N:1
instrumented_test = rule(
implementation = _instrumented_test_impl,
attrs = {
"binary": attr.label(cfg = address_sanitizer_transition),
},
test = True,
)
The shape of the API matches the shape of the change. If the rule's output belongs to the new configuration, attach the transition incoming. If only one specific dependency does — and the rule itself is still the user-facing target in the original configuration — attach the transition outgoing.
There is one ecosystem-wide constraint: Starlark transitions cannot be attached to native rules.1 A Starlark wrapper rule can declare an attr.label(cfg = my_transition) pointing at, say, cc_binary, but you cannot retrofit cc_binary itself with cfg = my_transition.
Native Options vs Build Settings
A transition reads and writes labels. For Starlark build settings (//pkg:my_setting), the label is the setting target. For built-in Bazel flags, Bazel exposes them under a special prefix:1
cpu_transition = transition(
implementation = _impl,
inputs = [],
outputs = ["//command_line_option:cpu"],
)
The //command_line_option: prefix is not a real package — it is a label-shaped namespace that gives transitions access to native option values without each option needing a Starlark counterpart. Most flags work this way. Two important ones do not:1
--define— not addressable through//command_line_option:define. New project-specific knobs should be 4.7.1 Build Settings for Rule Authors, not--definekeys.--config— not addressable at all.--configis an expansion flag that resolves to a list of other flags by reading.bazelrcfiles. The same--config=foomay even include flags Bazel cannot bind to a single configured target (such as spawn-strategy choices). Transitions must enumerate the underlying flags directly.1
Native options also explain how user-defined transitions relate to the built-in cfg = "exec" covered in 4.6.4 Execution Configuration for Tools (cfg = "exec"): the exec transition is a Bazel-managed transition that switches an attribute's dep subtree from the target platform to the execution platform. User-defined transitions are the same mechanism, generalized to anything addressable through //command_line_option:* or a Starlark build setting.
Reading Transitioned Dependencies
Adding cfg = transition to an outgoing attribute changes how the rule implementation must read it. ctx.attr.dep is forced to be a list even if the attribute was declared as a single label, because a transition could in principle produce more than one configured target (this is the path that 4.7.3 Split Transitions actually takes):1
def _rule_impl(ctx):
transitioned_dep = ctx.attr.dep[0]
For 1:1 transitions, the list always has one element. For split transitions returning a dict of dicts, ctx.split_attr.dep["Apple deps"] keys the deps by the transition's custom labels — ctx.attr.dep still works but its order is unspecified.1 The rest of the rule implementation is unchanged: the transitioned dep is a normal Target with normal providers, just analyzed under the modified configuration.
Transitions Are the Dual of select()
select() and transitions are the two sides of the configuration loop:1
| Mechanism | Direction | Where it lives | Granularity |
|---|---|---|---|
select() | reads configuration → picks attribute value | BUILD attributes | Per attribute |
| Transition | rule code → writes new configuration | Rule / attribute cfg | Per dependency edge |
A transition does not replace a select() — they cooperate. A common pattern is: a transition sets //config:build_mode = "debug" for a dep subtree, and somewhere inside that subtree a select() on //config:build_mode chooses extra debug sources, deps, or compiler options. The transition produces the configuration. select() consumes it.
This duality is also why the analysis-phase rules of 3.3.1 Configurable Attributes (select()) apply here: transitions run during analysis, not loading. They cannot inspect source contents, run tools, or branch on anything that only execution would know. They take a configuration in, return a configuration out.
When a New Configuration Appears, So Does a New Subtree
Every distinct configuration produces its own copy of every configured target underneath the transition point. That is the whole point — :my_lib compiled with --copt=-O0 is genuinely a different configured target from :my_lib compiled with --copt=-O2 -fsanitize=address, and Bazel must analyze and cache each one separately.
The cost shows up where the transition spreads further than intended. A transition attached to a rule's binary attribute is also inherited along the dep edges of binary: source files, code generators, data deps, and runtime tools all resolve under the transitioned configuration. Common libraries shared between a transitioned and a non-transitioned target then exist twice in the analysis graph, and a genrule that runs once in the parent's configuration may now run twice.4 In the worst case, each layer of a binary tree of dependencies appends one character to a setting, so n layers below the transition produce 2^n configured copies of the leaf.1
The remedies belong with the rest of the boundary discussion in 4.7.4 Transition Boundaries & Gotchas: scoping transitions to the smallest attribute that actually needs them, using config.none() for genuinely platform-neutral data deps, and debugging duplicate configurations with cquery / bazel config before reaching for output-directory naming flags.5 Higher-level libraries such as with_cfg exist precisely to keep this scope explicit by letting a rule author opt specific attributes out of the transition without writing the wrapper rules by hand.4
Debugging Unexpected Transitions
When a build re-analyzes a target that "should" have been cached, or cquery shows the same label twice with different hashes, the question is always which configuration each version saw. The diagnostic loop is:
bazel cquery 'deps(//target)'to list configured targets — each line includes the configuration hash in parentheses next to the label.bazel config <hash>to dump every flag value in a specific configuration, orbazel config <hash1> <hash2>to diff two of them — covered in 5.2.4bazel config— Configuration Inspection.- Match the diff against the transitions on the rule definitions in the path between the parent and the duplicate target.
Bazel 7+ removed an older shape of this surface: cquery now reports Starlark transitions with full fidelity, so the configuration hashes you see in cquery actually correspond to the transitions in .bzl code rather than the partial picture earlier versions returned.6
The Allowlist Attribute Is Gone
Older documentation and Bazel 6.x examples show every transition-using rule carrying a private attribute:
"_allowlist_function_transition": attr.label(
default = "@bazel_tools//tools/allowlists/function_transition_allowlist",
),
The intent was to let projects restrict which packages were allowed to define transitions, since transitions can blow up the build graph. In practice almost every rule used the default ("everyone allowed"), which made the attribute pure boilerplate. The requirement was removed in Bazel 7.1, and modern (Bazel 7.1+) rules do not need the attribute at all.7 If you read older transition examples, treat the attribute as historical noise and do not copy it into new rules. If you see it on rules you maintain, dropping it is safe on Bazel 7.1+.
The mini-ruleset places the attachment forms side by side in its transition declarations. For the observable shape of ctx.split_attr, run the split-transition-keys snippet and inspect how its branch keys reach analysis.
A Starlark transition is a function (settings, attr) -> {label: value, ...} declared with transition(implementation, inputs, outputs). Attach it to rule(cfg = ...) to change the rule's own configuration (1:1 only) or to attr.label(cfg = ...) to change a dependency subtree's configuration (1:1 or 1:N). Address native flags through the //command_line_option:* prefix, except for --define (use a build setting) and --config (enumerate the underlying flags). Each new configuration the transition introduces is a real new copy of the dep subtree — useful for cross-arch and instrumented builds, expensive when the transition leaks past the intended scope.
Check your understanding · 4 questions
1.A Starlark transition declares outputs = ['//pkg:optimization', '//pkg:instrumentation'] but its implementation only changes one of them. What must the returned dict look like?
Select one answer
2.Match each transition concept to where Bazel exposes it:
Drag each answer onto the matching prompt, or click an answer and then click a prompt
3.True or false: transitions and configuration mechanics in Bazel 7.1+.
Choose True or False for each sentence
4.Why does adding a Starlark transition to a deeply-shared library's binary attribute often blow up build times?
Select one answer
Footnotes
-
Configurations —
transition()declaration withinputs/outputs, incoming vs outgoing attachment points, 1:1 and 1:2+ shapes, no-op shorthand,//command_line_option:prefix,--define/--configrestrictions,ctx.attrforced to list /ctx.split_attraccess, and the exponential graph-growth case study. ↩1 ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 -
Glossary — definition of configured target as a
(target, configuration)pair, the unit Bazel actually analyzes and caches. ↩ -
transition — API reference for the
transition()builtin:implementationcallback signature(settings, attr),inputsas the key set of thesettingsdict,outputsas the required key set of the returned dict, list/dict-of-dicts return for split transitions. ↩1 ↩2 ↩3 -
with_cfg: Making transitions more accessible — motivation for scoping transitions: raw transitions leak to data deps and code generators, common deps build twice, and library APIs (reset_on_attrs, manual reset rules) exist to contain that spread. ↩1 ↩2 -
Utilizing Bazel for Cross-Platform and Cross-Architecture Compilation and Testing — production use of split transitions for macOS universal binaries (
//command_line_option:cpuflipped todarwin_x86_64anddarwin_arm64, deps consumed viactx.split_attr) and the--experimental_output_directory_naming_scheme=diff_against_baselineworkaround for redundant configurations. ↩ -
What's New in Bazel 7 — release note that "Starlark transitions now have full fidelity" in
cqueryoutput. ↩ -
Configurations (Bazel 6.4 docs) — historical requirement to declare
_allowlist_function_transitionon every rule using a Starlark transition, removed by issue #19233 and shipped in Bazel 7.1. ↩