3.3.1 Configurable Attributes (select())

Most BUILD attributes are static: srcs = ["main.cc"] always means main.cc, regardless of how the build is invoked. select() changes that. It lets an attribute resolve to different values depending on the current build configuration — which platform you target, which flags are active, or which custom settings are in effect1. If 0.3.8 select() Awareness introduced the shape of select(), this article covers the full mechanism: how conditions work, how to combine them, and what to do when a branch resolves unexpectedly.

cc_binary(
    name = "server",
    srcs = ["main.cc"],
    deps = select({
        "@platforms//os:linux": [":linux_net"],
        "@platforms//os:macos": [":darwin_net"],
        "//conditions:default": [":portable_net"],
    }),
)

Build with --platforms=//platforms:linux_x86 and deps resolves to [":linux_net"]. Build on macOS without any flag and it picks [":darwin_net"]. Build for an unlisted OS and "//conditions:default" catches it1. The attribute behaves as if only the matching branch exists — Bazel never analyzes the non-matching alternatives.

Where 3.2.4 Command Line Flags covered flags that feed the configuration, select() is the mechanism that reads the configuration inside BUILD files. The two work together: flags set the values. select() branches on them. Because configuration is one of the declared inputs that determine an action's identity, select() is also how builds stay hermetic across platforms — the correct dependencies and sources are selected deterministically from the configuration, not from ad hoc environment checks (2.3 Hermeticity & Sandboxing).

The maintainer-workspace app BUILD file shows the same pattern in a larger Level 3 project: one Java library selects the platform-specific source file while keeping a generic fallback.

Configuration Conditions

Each key in a select() dictionary is a label pointing to a condition target — a target that Bazel evaluates against the current configuration. Two kinds of targets serve as conditions1:

config_setting — a rule that bundles one or more expected flag values. It matches when every specified value matches the current build:

config_setting(
    name = "optimized_build",
    values = {"compilation_mode": "opt"},
)

constraint_value — a single platform property from the constraint system (covered in 3.3.3 Constraint Values). When used directly as a select() key, it matches if the target platform includes that constraint1:

deps = select({
    "@platforms//os:linux": [":linux_impl"],
    "@platforms//os:windows": [":windows_impl"],
})

Using constraint_value directly saves the boilerplate of wrapping every platform check in a config_setting. For compound checks (e.g., Linux and ARM64), a config_setting with constraint_values is still needed1:

config_setting(
    name = "linux_arm64",
    constraint_values = [
        "@platforms//os:linux",
        "@platforms//cpu:arm64",
    ],
)

What config_setting Can Match

config_setting supports several condition attributes. All specified entries across all attributes must match for the setting to trigger1:

AttributeMatches againstExample
valuesBuilt-in Bazel flags{"compilation_mode": "opt"}
flag_valuesStarlark build settings{"//config:feature": "enabled"}
define_values--define key-value pairs{"ENV": "prod"}
constraint_valuesPlatform constraints["@platforms//os:linux"]

Flag names in values drop the -- prefix and use the same parsing as the command line: "compilation_mode": "opt" matches bazel build -c opt1.

define_values is the legacy --define interface — it can also be expressed in values as {"define": "ENV=prod"}1. New projects should use Starlark build settings with flag_values instead — they provide type safety, label-based ownership, and tooling support2. The --define replacement story is covered in 3.2.4 Command Line Flags.

The Default Condition

//conditions:default is a built-in condition that matches when nothing else does1. Omitting it means an unmatched configuration is a build error:

Won't build
filegroup(
    name = "missing_default",
    srcs = select({
        "//platforms:linux_target": [":linux_runtime"],
        "//platforms:macos_target": [":macos_runtime"],
    }),
)
ERROR: .../app/BUILD.bazel:20:10: configurable attribute "srcs" in //app:missing_default doesn't match this configuration. Would a default condition help?

Conditions checked:
 //platforms:linux_target
 //platforms:macos_target

ERROR: Analysis of target '//app:missing_default' failed; build aborted

The first line tells you which attribute failed (srcs), which target owns it (//app:missing_default), and why Bazel could not choose a branch. Whether to include //conditions:default is a design choice. For platform-specific code where an unlisted platform genuinely cannot work, omitting the default produces a clear failure rather than a silent fallback to incorrect code1. For most attributes, providing a sensible default makes builds more robust.

For clearer failures, select() accepts a no_match_error parameter1:

deps = select(
    {
        "//platforms:android": [":android_deps"],
        "//platforms:ios": [":ios_deps"],
    },
    no_match_error = "This target requires --platforms set to Android or iOS",
)

The select-errors snippet keeps the missing-default and custom-no_match_error cases side by side so the two diagnostics can be reproduced from one workspace.

Reproduce this error

Match Ambiguity

When multiple conditions match the current configuration, Bazel requires exactly one of the following to be true1:

  • All matching conditions resolve to the same value — no conflict.
  • One matching condition is a strict specialization of all others — its values is a strict superset. For example, {"cpu": "x86", "compilation_mode": "dbg"} is a specialization of {"cpu": "x86"}, so the more specific one wins.

Otherwise, the build fails with an ambiguity error. This prevents silent, order-dependent behavior.

Combining Selects

select() supports composition through concatenation and Skylib helpers1.

Concatenation — multiple select() calls can be combined with +:

srcs = ["common.cc"] +
    select({
        ":linux": ["linux_impl.cc"],
        ":macos": ["macos_impl.cc"],
    }) +
    select({
        ":opt_mode": ["optimizations.cc"],
        "//conditions:default": [],
    }),

Each select() resolves independently. The results are concatenated. This is the natural way to combine orthogonal dimensions — platform and optimization level in this example.

Selects cannot be nested — you cannot put a select() inside another select(). If you need conditional logic along two axes, use concatenation (above) or factor one axis into an intermediate target1.

selects.with_or from bazel-skylib maps multiple conditions to the same value:

load("@bazel_skylib//lib:selects.bzl", "selects")

deps = selects.with_or({
    (":linux", ":freebsd", ":openbsd"): [":posix_impl"],
    "//conditions:default": [":generic_impl"],
})

selects.config_setting_group combines conditions with AND or OR logic as a standalone target, reusable across multiple rules1:

selects.config_setting_group(
    name = "linux_debug",
    match_all = [":linux", ":dbg_mode"],
)

selects.config_setting_group(
    name = "any_unix",
    match_any = [":linux", ":freebsd", ":macos"],
)

select() and the Phase Boundary

select() is resolved during the analysis phase, after BUILD files have been loaded but before actions execute. This has a critical consequence for macros: legacy macros run during the loading phase, before flag values are known, so they cannot inspect which branch a select() will take1,3.

A macro that tries to iterate or call methods on a select() value fails:

Won't build
def normalize(name, value):
    native.genrule(
        name = name,
        outs = [name + ".txt"],
        cmd = "echo %s > $@" % value.upper(),
    )
type 'select' has no method upper().

Macros can accept select() values and pass them through as opaque objects to rules — they just cannot inspect or transform them1. Rule implementations, on the other hand, receive the already-resolved value through ctx.attr and can work with it normally1. The runnable form of this "macro decides too early" trap lives in the legacy_macro_select definition wired up by the macro_select_error target, where the macro branches at loading time and never sees the resolved select() value.

Reproduce this error

This loading-vs-analysis distinction is the same phase model described in 2.2 Three Phases of a Build. It explains why select() is attribute-level conditional data rather than a general Starlark if — it cannot exist outside attribute contexts because it depends on configuration information that only exists at analysis time.

Boolean Truthiness Gotcha

One subtle consequence of the phase boundary: in a macro context, a select() object used as a boolean always evaluates to True, regardless of which branch would actually match1. This fails silently:

Wrong behavior
def my_macro(name, enable_feature):
    if enable_feature:  # always True when enable_feature is a select()
        ...

If enable_feature = select({":opt": True, "//conditions:default": False}), the if branch always executes because the macro sees the select() object (truthy), not its resolved value. This is one of the most common select() pitfalls — symptoms appear as builds that ignore configuration and always behave the same way.

Debugging: Why Does My select() Pick the Wrong Branch?

When a select() resolves unexpectedly, the diagnostic workflow uses cquery and bazel config1. bazel query operates at loading time and cannot resolve select() — it reports all branches as dependencies. cquery runs after analysis and shows the resolved graph:

$ bazel cquery 'deps(//myapp:server)' --platforms=//platforms:linux_x86
//myapp:server (12e23b9a2b534a)
//myapp:linux_net (12e23b9a2b534a)

The hash in parentheses identifies the configuration. Inspect it to see every flag value:

bazel config 12e23b9a2b534a

Compare the output against each config_setting's expected values to find the mismatch. This workflow is covered in depth in 5.2 Query. For the full platform and toolchain resolution model that determines which constraints are active, see 4.6 Toolchains & Platform Resolution.

key takeaway

select() is how BUILD files read the build configuration. Define conditions with config_setting (matching flags or constraints) or use constraint_value labels directly for single platform checks. Use //conditions:default for fallback behavior. Compose with + for orthogonal dimensions and Skylib's selects.config_setting_group for AND/OR logic. Remember that select() resolves at analysis time — macros can pass it through but never inspect it. When a branch mismatch occurs, cquery plus bazel config reveals which flag values are active. For modifying configuration within the build graph — changing flags for a subtree of dependencies — see 4.7 Build Settings & Transitions.

Check your understanding · 3 questions

1.A macro receives enable_feature = select({':opt': True, '//conditions:default': False}) and tests 'if enable_feature:'. What happens?

Select one answer

2.Match each select() composition tool to its purpose:

Drag each answer onto the matching prompt, or click an answer and then click a prompt

Answers
+ (concatenation)
selects.with_or
selects.config_setting_group(match_all)
selects.config_setting_group(match_any)

3.True or false about select() resolution and diagnostics:

Choose True or False for each sentence

bazel query can show which select() branch will be chosen for a given configuration.
bazel cquery shows the resolved dependency graph after select() has been evaluated for the specified configuration.
Omitting //conditions:default always produces a clearer error than including it.
0 of 3 answered

Footnotes

  1. Configurable Build Attributes — full select() reference, config_setting patterns, combining selects, macro limitations, cquery debugging, platform matching semantics 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20

  2. Configurations — Starlark build settings as typed replacement for --define, flag_values integration with config_setting

  3. Bazelizing Open Image Denoise - Part 4: Issue with black stripes solved — loading-phase vs analysis-phase distinction: macros cannot process select() values