4.7.4 Transition Boundaries & Gotchas
extraTransitions do exactly what they say: they apply a configuration delta to a target and propagate that configuration to everything reachable from that target's outgoing edges.1 Most production transition bugs are not failures of that mechanism. They come from the graph: which edges actually carry the new configuration, what was supposed to be data instead of code, and how Bazel names the resulting output directories. Once 4.7.2 Starlark Transitions and 4.7.3 Split Transitions are in place, the remaining work is keeping their footprint contained.
Transitions Spread Along Every Outgoing Edge
A transition attached to a rule (or to a single dependency attribute) reconfigures the entire subtree below the transition point. In the worst case, a single --//foo:owner flag, mutated by every pkg:i_b along a depth-n chain, produces (2^n) configured copies of one shared pkg:dep.2 That is the worst case, but the same shape appears in everyday rules: a cc_test wrapper that turns on Address Sanitizer reaches its srcs, its deps, its data, and any genrule it depends on for code generation, and a base library shared with a non-instrumented binary now exists in two configurations.3
The mechanism is not subtle. A transition declares its outputs, the implementation function returns the new values, and Bazel applies them to every outgoing edge of the configured target.1 The fix is to constrain which edges carry the configuration, not to weaken the transition.
Decide: A wrapper rule adds a transition to its broad deps attribute so users do not have to pass a board flag. The firmware output is now correct, but a shared logging library and a code generator underneath deps both show up as duplicate configured targets. Is the transition function wrong, or is the graph boundary wrong?
Reveal
The suspicious part is the boundary. The transition may set the right value, but attaching it to a broad edge lets that value flow through every reachable dependency below the wrapper. Bazel then has to analyze targets that did not conceptually need the board setting in another configuration.
The fix starts by naming the narrow edge: which dependency subtree actually needs the different configuration, and which deps should stay in the caller's configuration or no configuration at all? If the value is really part of a selected platform, 4.7.5 Platform-based Flags may remove the custom transition entirely. If it is a true rule-specific graph split, attach the transition where that split begins, not where it is merely convenient for the command line.
Concretely:
- Audit every outgoing edge from the rule that applies the transition. Anything that should not pick up the new configuration is a candidate for an explicit reset.
- Treat data-only edges (read-only fixtures, test inputs, documentation files) and code-generator edges (genrules whose output is consumed regardless of the build mode) as non-transitioned. Their results do not benefit from forking.
- Treat host-tool edges as already non-transitioned. Tools used to produce artifacts already run in the exec configuration and should not also be wrapped in a target-side transition.
The with_cfg library is built around exactly this pain point. A naïve transition on a cc_test leaks to all dependencies — including data deps and code generators — and builds common deps twice. The library responds with two boundary tools: _builder.reset_on_attrs("srcs") (or any other attribute) prevents the transition from reaching that subtree, and a generated *_original_settings rule lets a single dependency opt out of the surrounding transition entirely — for example, a Java 7 target deliberately kept inside a Java 21 library.3 These are not workarounds. They encode the boundary that a hand-written transition() has to enforce by other means.
The same boundary discipline scales up. A cc_test macro can generate every (platform × sanitizer) combination from a single declaration, but the trade-off is direct: the analysis graph grows much larger, and compiling every combination in parallel consumes significantly more disk space.4 Multi-config matrices are powerful, but every fork is a real cost paid in analysis-graph nodes and on-disk outputs.
Apple multi-architecture rules provide a concrete diagnostic route. The
rules_ios transition guide
shows how nested 1:N transitions multiply configured targets and uses aquery
to inspect the actions registered for each configuration.5 That is analysis
evidence, not proof that every registered action executed: confirm execution
with a profile or execution log, and account for cache hits before attributing
wall time to the transition. The repository map
routes from that guide to the split-selection implementation when the configured
graph still looks wrong.
config.none() Strips Configuration From Data Edges
When the dependency really is data — a static file, a checked-in fixture, a payload that should be analyzed once regardless of the surrounding build — the right tool is config.none(). It is a built-in transition object that removes all configuration from the dependency, intended for the case where the dependency is data-only and contains no code that needs to be built but should only be analyzed once.6
In rule code, attach it to the data attribute:
my_rule = rule(
implementation = _impl,
attrs = {
"src": attr.label(allow_single_file = True),
"data": attr.label_list(cfg = config.none(), allow_files = True),
},
)
With cfg = config.none(), a single configured node serves every parent configuration that reaches the data target. Without it, each surrounding transition produces another configured copy of the same files, and the analysis-phase work multiplies for no benefit. Because config.none() removes the target configuration, reserve it for data that is genuinely independent of the target platform. If a data dependency is platform-specific, model that restriction on a configured edge or on the consuming target instead of using config.none() to erase the distinction. The same config module also exposes config.exec() for execution transitions and config.target() as an explicit no-op, but config.none() is the one that solves the data-fork problem.6
config.none() is not a license to "make this dep cheap." If the target produces compiled artifacts, removing its configuration silently drops the ability to vary by platform, compilation mode, or sanitizer flags. Reach for it when the dependency is genuinely data. Reach for an explicit reset (or a separate top-level entry point) when it is code that simply should not move with this transition.
Output Directory Deduplication: Mostly Already Fixed
The historical version of this gotcha is well known: write a transition, then write another transition that effectively undoes it, and Bazel produces two identical configurations under different output directories — the same actions run twice, the cache does not match, and a "round-trip" path through transitions doubles the build. It surfaced in practice on macOS universal binaries, where the fix was --experimental_output_directory_naming_scheme=diff_against_baseline to make Bazel recognize identical configurations and build them only once.7
That advice was correct for Bazel 6, where legacy was the default. In current Bazel, the default of --experimental_output_directory_naming_scheme is diff_against_dynamic_baseline, which already names output directories by diffing each configuration against the build's baseline (and against the post-exec baseline for exec configurations). Identical configurations land in the same directory by construction, so the historical "round-trip" duplication is no longer the typical experience.8
What this means for someone writing transitions today:
- The default behavior is already the deduplicated one. Setting
--experimental_output_directory_naming_scheme=diff_against_baselineexplicitly is rarely necessary on modern Bazel, and=legacyis what you would only ever set to compare against historical behavior or to debug a regression. - Distinct configured copies of the same label are now usually a real configuration difference, not just an output-directory naming artefact. Use 5.2.4
bazel config— Configuration Inspection to confirm it:bazel cqueryshows each configured target's hash, andbazel config <hash1> <hash2>lists exactly which flags differ. If one configured target is reachable by several paths with the same hash, that is normal graph sharing, not duplication to fix. Investigate the rule's transition boundaries only when the same label fans out to distinct configuration hashes.
If the project still builds with Bazel 6, treat the explicit flag as part of the migration toward a modern default rather than a permanent piece of configuration.
When The Boundary Argument Points To Platforms
Boundary control becomes harder as the number of orthogonal knobs grows. A transition that carries platform, sanitizer, and compilation mode is several boundaries layered on top of each other, and every wrapper rule has to maintain them. 4.7.5 Platform-based Flags is the alternative for the cases where the configuration is really "build for this device": move the build-setting values into the platform itself, select the platform, and let toolchain resolution carry the configuration without a custom transition per binary. It does not replace transitions for cross-cutting concerns like sanitizers or feature flags, but it does shrink the surface where boundary control has to be enforced by hand.
For the cases that remain transition-shaped, the 6.6.9 Bottleneck-Driven Optimization optimization is a graph-side complement: trimming unused configuration fragments reduces how often a flag change actually invalidates a configured target, which makes a wider transition footprint less expensive when boundary control is incomplete. It does not remove the need to contain transitions, but it lowers the cost of getting the containment slightly wrong.
Transition gotchas are graph problems, not syntax problems. The transition mechanism does what it is told. The work is choosing which outgoing edges carry the new configuration, using config.none() for data-only deps, and trusting the modern output-directory naming default instead of carrying the legacy fix as a permanent flag.
When in doubt, use bazel cquery plus bazel config to check whether apparent fan-out is distinct configurations or several paths to one shared configured target.
Check your understanding · 4 questions
1.A transition-enabled test wrapper is reconfiguring static fixtures and code generators that should not vary with the test's sanitizer flag. What is the first rule-author fix to consider?
Select one answer
2.Which of the following are appropriate uses of config.none() on a dependency attribute?
Select all that apply
3.True or false: --experimental_output_directory_naming_scheme in Bazel 6 vs Bazel 7+.
Choose True or False for each sentence
diff_against_dynamic_baseline.=diff_against_baseline is usually necessary to deduplicate identical configurations. The default already deduplicates.legacy is the only one that disables the round-trip-transition deduplication.cquery hash is normal graph sharing, not a rule-side duplication to fix.4.Match each transition-boundary tool to the situation it fits:
Drag each answer onto the matching prompt, or click an answer and then click a prompt
config.none() on a data attributewith_cfg(...)._builder.reset_on_attrs("srcs")bazel cquery //foo:bar plus bazel config <hashA> <hashB>Footnotes
-
Configurations — transition mechanism,
inputs/outputsdeclaration, attaching to incoming vs outgoing edges, and the rule that the implementation must return exactly the declared outputs. ↩1 ↩2 -
Configurations — "Memory and performance considerations" and the depth-
nworked example showing how a per-edge transition produces (2^n) configured copies of a shared dep. ↩ -
with_cfg: Making transitions more accessible — the leak to data deps and code generators, common deps building twice,
reset_on_attrs()for attribute-level boundaries, and the per-dependency reset rule. ↩1 ↩2 -
Multi-platform & Sanitizer Builds With One Command — analysis-graph and disk-space cost of generating every (platform × config) combination of a target. ↩
-
rules_ios repository map — public multi-architecture transition guide,
aqueryinspection boundary, and focused implementation escalation path. ↩ -
config —
config.none()removes all configuration for data-only deps.config.exec()andconfig.target()are the other built-in transition constructors. ↩1 ↩2 -
Utilizing Bazel for Cross-Platform and Cross-Architecture Compilation and Testing — the round-trip transition duplication on macOS universal binaries and the historical
--experimental_output_directory_naming_scheme=diff_against_baselinerecommendation. ↩ -
Verified against
CoreOptions.javaonmasterand at the Bazel 7.0.0 / 8.0.0 tags:experimental_output_directory_naming_schemedefaults todiff_against_dynamic_baseline, and the valuelegacyis the only one that turns the deduplication off. Bazel 6.0.0 only offeredlegacy(default) anddiff_against_baseline. The flag is hidden frombazel help build(documentation categoryUNDOCUMENTED) but still parsed.bazel build --experimental_output_directory_naming_scheme=invalidlists the three accepted values. ↩