4.7.1 Build Settings for Rule Authors
A build setting is a single piece of Bazel's configuration — one entry in the {key: value} map that Bazel calls a configuration. The built-in entries (--compilation_mode, --copt, --cpu, …) ship inside the Bazel binary. Starlark build settings are defined in .bzl files instead, so rule authors can add new typed configuration knobs without waiting for a Bazel release.1 3.2.4 Command Line Flags already introduced them from the user's side — typed replacements for --define, set on the command line with --//pkg:flag=value. This article is the rule-author's side: how to declare the setting, validate it, expose providers, and connect it to select() from 3.3.1 Configurable Attributes (select()) and to transitions in 4.7.2 Starlark Transitions.
Anatomy of a Build-Setting Rule
A build setting is a regular Starlark rule with one extra parameter on rule(): build_setting. The argument is a descriptor from the top-level config module that fixes the setting's type and decides whether users may set it from the command line.1,2
# example/buildsettings/build_settings.bzl
OptimizationProvider = provider(fields = ["level"])
_ALLOWED = ["none", "size", "speed"]
def _impl(ctx):
value = ctx.build_setting_value
if value not in _ALLOWED:
fail("{}: expected one of {}, got '{}'".format(
ctx.label, _ALLOWED, value,
))
return OptimizationProvider(level = value)
optimization_level = rule(
implementation = _impl,
build_setting = config.string(flag = True),
)
Three things happen when build_setting is set on a rule:
- Bazel automatically adds a mandatory
build_setting_defaultattribute whose type matches the descriptor —optimization_level(name = "optimization", build_setting_default = "none")is how aBUILDfile picks the baseline value.3 - Inside the implementation,
ctx.build_setting_valueexposes the current value (after any command-line override or transition). Reading it from a non–build-setting rule is an error.4 - If
flag = True, the setting is callable as--//pkg:name=valueon the command line and writable by transitions. Settings declared withflag = Falsecan only be changed by transitions, which is the right choice for internal knobs that users should not flip directly.1
Anything else about the rule is unchanged. A build-setting rule has an implementation function, returns providers, and shows up in select() like any other target — its only specialty is owning one entry in the configuration map.
The config Type Descriptors
The config module exposes one descriptor per supported Starlark type. Each takes flag = True/False and decides what Bazel will accept on the command line:2
| Descriptor | Starlark type | Notes |
|---|---|---|
config.bool() | bool | CLI: --//pkg:flag enables, --no//pkg:flag disables.1 |
config.int() | int | — |
config.string() | string | Historically supported allow_multiple = True. That parameter is now deprecated — use config.string_list(repeatable = True) instead.2 |
config.string_list() | list[string] | Comma-separated on the command line (--//pkg:flag=foo,bar), or with repeatable = True accept multiple --//pkg:flag=... occurrences that each contribute one element.2 |
config.string_set() | set[string] | Same surface as string_list, but element order and duplicates are not preserved. Prefer this over string_list when order does not matter — it reduces unnecessary configuration forking.2 |
A rule that does not need command-line exposure can still benefit from typing. build_setting = config.int(flag = False) produces an int-typed configuration entry whose value can only be changed by a transition, which is exactly what an internal split-by-shard or "instrumented build" knob usually wants.
Validating Values and Returning Providers
ctx.build_setting_value only enforces the Starlark type (string, int, bool, list, set). Any further constraint — allowed values, ranges, mutually exclusive combinations — belongs in the implementation function, which can fail() with a useful error and/or transform the raw value into a richer provider for consumers.1
Two patterns are common:
- Enum-style settings validate against a literal allow-list and return a custom provider carrying the parsed value (the
OptimizationProviderexample above). Other rules then declareattrs = {"optimization": attr.label(providers = [OptimizationProvider])}and read the value asctx.attr.optimization[OptimizationProvider].level. - Pass-through settings simply return a small wrapper provider so the value can be consumed uniformly through a normal label dependency.1
There is one important asymmetry. A consumer rule that depends on a build setting as a label sees whatever providers the implementation function returned. But other references to the same setting — for example, the settings dict passed to a transition implementation, or the matching done by config_setting — see only the basic Starlark-typed value, not anything the implementation function added.1 Validation that lives in the implementation function therefore protects consumers, not transitions. Transitions are free to write any value the descriptor's type permits.
Skylib Shortcuts for the Common Cases
Most projects do not need a custom implementation function for their flags. Skylib's common_settings.bzl ships pre-defined build-setting rules — string_flag is the common worked example — that return a simple BuildSettingInfo wrapper around the raw value. string_flag and string_setting also support a values allow-list for string enums. For richer validation on bools, ints, lists, or cross-field constraints, write a custom build-setting rule.5,1
load("@bazel_skylib//rules:common_settings.bzl", "string_flag")
string_flag(
name = "environment",
values = ["dev", "staging", "prod"],
build_setting_default = "dev",
)
Use Skylib for plain string enums or simple typed knobs. Write your own build-setting rule when you need real validation, derived providers, or a more interesting type than the descriptors directly support.
Before adding a custom setting, check whether a built-in Bazel option or a Skylib common setting already fits the problem.1 A project-specific flag is public configuration surface: once rules, select() branches, and .bazelrc aliases depend on it, changing its type or meaning becomes a migration.
Command-Line Exposure and Aliases
When flag = True, the setting's full label is its flag name on the command line:1
bazel build //my/target --//example:environment=prod
bazel build //my/target --//example:boolean_flag # bool: set true
bazel build //my/target --no//example:boolean_flag # bool: set false
Long labels turn into noisy command lines fast. --flag_alias=alias=//pkg:target binds a build setting to a short name. It is meant to live in .bazelrc rather than being typed on every invocation:6,1
# .bazelrc
build --flag_alias=env=//example:environment
bazel build //my/target --env=prod
Repeated aliases silently take the last definition, which makes "the build behaves differently than I expected after I changed .bazelrc" a real failure mode. Pick aliases that are unique across a project and prefer keeping them in one bazelrc layer. 3.2.4 Command Line Flags covers the user-side experience in more depth. The rule-author side is just deciding which settings deserve a friendly alias.
Target Scope vs Universal Scope
Build settings normally affect the target configuration only. That is what you want for product-facing choices such as "build this binary for prod" or "select this device configuration." It also means the same setting is not automatically applied to the execution configuration used to build tools.1
When a setting intentionally controls both the target being built and the tools that run during the build, set scope = "universal" on the build-setting target:
bool_flag(
name = "use_this_for_compiler_and_sources",
scope = "universal",
)
Use this sparingly. A universal setting couples target and exec configurations, so it should represent a value that genuinely must be identical on both sides of the build, not a shortcut for avoiding an explicit toolchain or execution-platform decision.
Plugging Into select()
A typed build setting becomes a branch key for select() through config_setting's flag_values attribute:1
config_setting(
name = "is_prod",
flag_values = {"//example:environment": "prod"},
)
cc_library(
name = "auth",
srcs = select({
":is_prod": ["auth_prod.cc"],
"//conditions:default": ["auth_dev.cc"],
}),
)
The value in flag_values is always a string. Bazel parses it into the setting's declared type when matching.1 The rest of the select() story — //conditions:default, no_match_error, combining selects across dimensions — is covered in 3.3.1 Configurable Attributes (select()). From the rule author's point of view, the only obligation is to give the setting a type and a label that downstream config_setting targets can quote.
Label-Typed Settings: label_flag and label_setting
config.string, config.int, etc., are for value-typed settings. For label-typed configuration — "which target should this rule use for X?" — Bazel ships two built-in rules instead: label_flag (settable on the command line) and label_setting (changeable only by transitions).1 You cannot create new label-typed settings via build_setting = config.label(). The built-ins are the API.
These behave like a configurable indirection. A consumer depends on the label_flag as a regular label. Bazel resolves the dependency to whatever target the flag currently points at and forwards that target's providers. Transitions can rewrite the label dynamically, which is the modern Starlark replacement for many built-in late-bound default attributes.1
The Pigweed embedded-Bazel project uses this pattern to let any board provide its own FreeRTOSConfig.h without patching the upstream FreeRTOS source:7
# Upstream-checked BUILD file
cc_library(
name = "freertos",
deps = [":freertos_config"],
)
label_flag(
name = "freertos_config",
build_setting_default = ":default_freertos_config",
)
cc_library(
name = "default_freertos_config",
target_compatible_with = ["@platforms//:incompatible"],
)
A downstream board sets --@freertos//:freertos_config=//my/board:freertos_cfg (through a transition, or through platform-based flags in 4.7.5 Platform-based Flags) and freertos picks up the board-specific config without source patches. The default is intentionally incompatible so that an unconfigured build fails fast instead of silently producing an unusable artifact.
The Legacy Bridge: fragments and configuration_field
Before Starlark build settings existed, rule authors could only read Bazel's built-in configuration through configuration fragments — typed accessors like ctx.fragments.cpp and ctx.fragments.coverage.8 A rule that reads any fragment must declare it on rule(). Reading an undeclared fragment is an analysis error.8
def _impl(ctx):
copts = ctx.fragments.cpp.copts
# ...
my_rule = rule(
implementation = _impl,
fragments = ["cpp"],
)
The companion API is configuration_field, which produces a late-bound default for a Label-typed attribute. The value is "late-bound" in the sense that it depends on the current configuration and is resolved at analysis time, not at load time.9 The classic example is the coverage merger tool: an implicit attribute whose default is whatever target --coverage_output_generator points at, and which is only built when coverage is requested:8
my_test = rule(
test = True,
implementation = _impl,
attrs = {
"_lcov_merger": attr.label(
default = configuration_field(
fragment = "coverage",
name = "output_generator",
),
executable = True,
cfg = config.exec(exec_group = "test"),
),
},
)
For new code, prefer build settings and toolchains: a label_flag is the Starlark-native replacement for most configuration_field patterns, and toolchains in 4.6.2 Defining, Registering & Accessing Toolchains replace many cases where rules used to reach into a cpp or java fragment. Declare fragments = [...] only when a rule genuinely needs a built-in fragment value that no Starlark API exposes yet.
Foundation for Transitions
The connection to 4.7.2 Starlark Transitions is direct: a Starlark transition declares which build settings it reads (inputs) and which it writes (outputs), both as labels.10 A transition() that writes //example:environment works only because the rule form of that setting exists in the build graph. The same is true for label_flag targets. In other words, the build-setting rule is the in-graph object that gives the transition a stable address — without it, there is nothing for inputs/outputs to point at. Built-in flags piggy-back on the same mechanism through the //command_line_option:* namespace, with --define and --config carved out for reasons covered in 4.7.2 Starlark Transitions.
The platform-based-flags snippet provides a runnable build-setting rule. Its config.bool(flag = True) declaration is the address later read by platform mappings.
A build setting is a typed entry in Bazel's configuration map, represented by a target. Define one with rule(build_setting = config.<type>(flag = True)), read it inside the implementation through ctx.build_setting_value, and validate / wrap it before returning providers. Use Skylib's string_flag / bool_flag / int_flag for plain knobs, set scope = "universal" only when the setting must apply to both target and exec configurations, use label_flag and label_setting when the value is a target reference, and reach for fragments + configuration_field only when bridging to legacy built-in configuration. Everything in this article is the foundation that 4.7.2 Starlark Transitions stands on — transitions need a labeled, typed setting before they can read or write anything.
Check your understanding · 4 questions
1.A rule author wants a typed configuration knob that users can flip on the command line and that downstream rules can read for analysis-time decisions. Which definition is correct?
Select one answer
2.Which statements about reading a build setting from a rule implementation are correct?
Select all that apply
3.Match each Bazel API to its primary role in the build-settings system:
Drag each answer onto the matching prompt, or click an answer and then click a prompt
config.string(flag = True)label_flagconfiguration_field(fragment = "coverage", name = "output_generator")config_setting(flag_values = {...})4.True or false: statements about the legacy fragments / configuration_field bridge and command-line addressing in transitions.
Choose True or False for each sentence
ctx.fragments.cpp.copts must declare fragments = ["cpp"] on its rule() call, otherwise the access is an analysis error.configuration_field() is the modern Starlark replacement for label_flag. New code should prefer configuration_field() over label_flag.//command_line_option:* namespace.--define is a legacy escape hatch and is not addressed through the Starlark build-setting label syntax.scope = "universal" when the same setting should apply to both target and exec configurations.Footnotes
-
Configurations — user-defined build settings as a single entry in the configuration map,
build_settingrule parameter,ctx.build_setting_value, implicitbuild_setting_default, command-line--//pkg:flag=valueand--no//pkg:flagboolean syntax,scope = "universal"for target plus exec configuration,flag_aliasaliases,config_setting.flag_valuesintegration, label-typedlabel_flag/label_setting,//command_line_option:*prefix and the deliberate--define/--configexclusions, and transition inputs/outputs by label. ↩1 ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 -
config — descriptors
config.bool,config.int,config.string,config.string_list,config.string_setwith theirflagandrepeatableparameters.string_setrecommendation overstring_listto avoid configuration forking.allow_multipledeprecation in favor ofstring_list(repeatable = True). ↩1 ↩2 ↩3 ↩4 ↩5 -
.bzl files —
rule.build_settingadds a mandatorybuild_setting_defaultattribute whose type matches the descriptor. ↩ -
ctx —
ctx.build_setting_valueexposes the current value. Reading it from a non–build-setting rule is an error. ↩ -
Configurable Build Attributes — Skylib
common_settings.bzlshipsstring_flagwith avaluesallow-list andbuild_setting_default, plus analogous simple wrappers for common typed settings. ↩ -
User's Manual —
--flag_alias=alias_name=target_pathbinds a long build-setting label to a short alias. ↩ -
How to use Bazel for embedded development —
label_flagforFreeRTOSConfig.hin upstream FreeRTOS without source patches, with an intentionally incompatible default target. ↩ -
Rules — configuration fragments require
rule(fragments = [...]), undeclared fragment access is an error, andconfiguration_field()provides late-bound defaults (coverage_lcov_mergerexample). ↩1 ↩2 ↩3 -
LateBoundDefault — late-bound default attribute values of type
Labelresolved at analysis time based on the current configuration. ↩ -
transition — transitions declare
inputs(build settings they may read) andoutputs(build settings they may write) by label. The implementation function receivessettingsas a{label: value}dict. ↩