4.2.4 Error Handling & Validation

Good rule errors are part of the rule's public API. A custom rule should reject invalid input at the earliest layer that has enough information: the attribute schema rejects shape problems, fail() rejects analysis-time semantic problems, provider initialization protects provider invariants, and validation actions check facts that only exist after files are built.1,2

Validate where the information exists
Move each check to the first layer that has enough information.
  1. attr.* schema
    Reject shape problems
    Let Bazel validate the BUILD API before implementation logic runs.
    declarative shape
    Knows BUILD attr shape and user-supplied values
    Rejects missing attrs, wrong values, provider mismatch
    mandatory values providers allow_single_file
  2. implementation fail()
    Reject semantic problems
    Use ctx to compare configured attrs and providers. A valid result may then register actions.
    analysis invariant
    Knows ctx during analysis after attrs are configured
    Rejects conflicts, mismatches, unsupported modes
    ctx.attr ctx.files dep[Info] rule policy
  3. provider(init = ...)
    Reject provider contract problems
    Keep reusable invariants with the provider other rules consume.
    interface boundary
    Knows provider constructor args before propagation
    Rejects empty fields, bad enums, invalid payloads
    nonempty fields normalized depset shared contract
  4. ordinary execution action
    Reject artifact-content problems
    Model checks that need file contents as execution-phase actions. These checks can fail during execution.
    execution check
    Knows source and generated files during execution
    Rejects lint failures, bad metadata, style violations
    lint static analysis generated metadata style check
Put the action's marker in the special OutputGroupInfo(_validation = ...) group.
Normal builds request it without wiring the marker into the main action. Tool and exec dependencies are exceptions.
Prefer the earliest layer with enough information: schema and analysis, provider invariants, then artifact validation.

Validate At The Right Layer

Rule authors have more than one validation tool. The mistake is using fail() for everything. If Bazel already knows a value is missing, has the wrong file extension, or lacks a required provider, the attribute schema should express that. If the check needs configured values or provider contents from direct dependencies, the implementation function is the right place. If the check needs to read source or generated file contents, analysis is too early. Rules cannot read artifacts during analysis, so the validation must be modeled as an action.3

LayerKnowsUse it for
attr.* schemaUser-supplied attribute shaperequired attrs, file extension filters, executable tools, allowed string values, required providers
implementation fail()configured attrs and direct dependency providerscross-attribute invariants, semantic compatibility, rule policy
provider(init = ...)provider constructor argumentsreusable provider invariants and normalized provider construction
_validation output groupsource/generated artifact contentslint, static analysis, consistency checks, style checks

This ordering keeps errors close to the bad input. It also keeps the action graph clean: an invalid target should fail during analysis before the rule registers actions that cannot produce a meaningful output.

Let Attributes Reject Shape Problems

The attrs map of rule() is the first validation boundary. It declares the BUILD-file API for the target kind, and Bazel uses it before your implementation logic has to inspect values manually.4 Use it for structural constraints:

def _impl(ctx):
    # Semantic checks go here. Shape checks belong in attrs below.
    pass

my_codegen = rule(
    implementation = _impl,
    attrs = {
        "srcs": attr.label_list(
            mandatory = True,
            allow_files = [".schema"],
            allow_empty = False,
        ),
        "mode": attr.string(
            default = "archive",
            values = ["archive", "tree"],
        ),
        "lang": attr.string(
            default = "py",
            values = ["py", "go"],
        ),
        "deps": attr.label_list(
            providers = [CodegenInfo],
        ),
        "_tool": attr.label(
            default = Label("//tools:codegen"),
            executable = True,
            cfg = "exec",
        ),
        "_validator": attr.label(
            default = Label("//tools:validate_schema"),
            executable = True,
            cfg = "exec",
        ),
    },
)

mandatory = True says the user must set the attribute explicitly. allow_files narrows label attributes to source files with expected extensions, while allow_single_file is the single-file variant exposed through ctx.file.<name>. values restricts a string attribute to a known set. providers requires dependencies to return the provider contract the rule consumes. The list-of-lists form means "all providers from at least one allowed set."5

These checks produce Bazel-generated diagnostics, not handcrafted prose, but that is usually better than duplicating the same condition in every implementation. Reserve custom messages for rule-specific meaning the schema cannot express.

Use fail() For Semantic Invariants

Inside the implementation function, fail() aborts evaluation with an error message.6 Use it when the rule has enough analysis-time information to know it cannot produce a valid target.

def _impl(ctx):
    if ctx.attr.mode == "archive" and len(ctx.files.srcs) != 1:
        fail(
            "%s: attr 'mode' is 'archive', so attr 'srcs' must contain "
            "exactly one file; got %d"
            % (ctx.label, len(ctx.files.srcs)),
            stack_trace = False,
        )

    for dep in ctx.attr.deps:
        info = dep[CodegenInfo]
        if info.lang != ctx.attr.lang:
            fail(
                "%s: attr 'deps' contains %s with lang '%s', but this "
                "target has lang '%s'"
                % (ctx.label, dep.label, info.lang, ctx.attr.lang),
                stack_trace = False,
            )

    # Declare outputs and actions only after invariants are known.

The message should name the target, the attribute, the actual value, and the expected value. That is not decoration. It lets the BUILD author fix the call site without opening the rule implementation. The fail() API accepts positional message parts and has a stack_trace parameter. Setting it to False elides the stack trace for friendlier user-facing failures.7

Do not use fail() to check file contents. A rule implementation can inspect ctx.attr, ctx.file, ctx.files, ctx.executable, and providers from dependencies, but it cannot open source files or read generated outputs during analysis.8 If the rule needs to validate "this generated JSON contains a required field," that is an action, not an implementation-time branch.

Put Provider Invariants In Provider Constructors

Some checks belong to a provider, not to one particular rule. provider(init = ...) lets a provider preprocess and validate constructor arguments before producing the provider instance.9

def _codegen_info_init(*, outputs, lang, transitive_outputs = None):
    if not outputs:
        fail("CodegenInfo.outputs may not be empty", stack_trace = False)
    if lang not in ["py", "go"]:
        fail("CodegenInfo.lang must be 'py' or 'go', got '%s'" % lang)

    return {
        "outputs": depset(outputs, transitive = transitive_outputs or []),
        "lang": lang,
    }

CodegenInfo, _new_codegen_info = provider(
    fields = {
        "outputs": "depset of generated files",
        "lang": "language emitted by the generator",
    },
    init = _codegen_info_init,
)

This makes the invariant travel with the interface. Any rule or helper that constructs CodegenInfo gets the same checks, and downstream rules can trust the provider shape. The raw constructor returned alongside the provider symbol bypasses init, so bind it to a private name and expose only deliberate factory functions when you need an alternate construction path.10 Provider declaration mechanics continue in 4.2.7 Custom Provider Declaration, and the composability pattern continues in 4.2.8 Provider-as-Interface Pattern.

Validate Artifacts With Actions

Some important checks need files: linting source code, checking generated metadata, scanning dependency consistency, or enforcing style. Those checks run in the execution phase as actions. The rule creates a normal action with an output file, then returns that output in OutputGroupInfo(_validation = depset([...])). The general output-group API continues in 4.2.9 OutputGroupInfo & Output Groups.11

def _impl(ctx):
    out = ctx.actions.declare_file(ctx.label.name + ".out")
    report = ctx.actions.declare_file(ctx.label.name + ".validation")

    args = ctx.actions.args()
    args.add(out.path)
    args.add_all(ctx.files.srcs)

    ctx.actions.run(
        outputs = [out],
        inputs = ctx.files.srcs,
        executable = ctx.executable._tool,
        arguments = [args],
    )

    validation_args = ctx.actions.args()
    validation_args.add(report.path)
    validation_args.add_all(ctx.files.srcs)

    ctx.actions.run(
        outputs = [report],
        inputs = ctx.files.srcs,
        executable = ctx.executable._validator,
        arguments = [validation_args],
        mnemonic = "CodegenValidation",
    )

    return [
        DefaultInfo(files = depset([out])),
        OutputGroupInfo(_validation = depset([report])),
    ]

The validation output should not be added to DefaultInfo or to the inputs of ordinary build actions. The point of _validation is to request validation outputs without forcing them into the critical path of the main artifact action.12 Normal caching and incrementality still apply: if the validation action's inputs are unchanged and the action previously succeeded, Bazel does not need to rerun it.13

There are limits. Validation actions still need an output file, even if the tool normally only exits with success or failure. They are not run when the target is depended on as a tool, when it is reached through an implicit dependency, or when it is built in the exec configuration.14 If those cases matter for your rule, document the behavior and cover it with tests.

Test The Error Contract

Error handling needs tests because it is user-visible behavior. Analysis tests are the standard way to inspect the inner behavior of custom rules: they can depend on a target under test and assert on providers or actions during analysis.15 For validation actions, the official rules guide recommends testing that validation outputs are not accidentally added as inputs to other actions, because Bazel does not enforce that separation for you.16

One subtlety: an analysis test should not call fail() for ordinary assertion failures, because that turns the test into an analysis-time build break rather than a test failure. The testing framework stores assertion errors and reports them through the generated test result instead.17 The detailed mechanics belong in 4.5.1 Analysis-Phase Testing, but the design rule starts here: treat diagnostics, provider invariants, validation outputs, and tests as one contract.

The mini-ruleset makes two layers concrete: glyph_library rejects empty sources during analysis, then its GlyphValidate action checks produced content during execution. The returned _validation output is visible in the same rule's provider list.

key takeaway

Prefer declarative validation where Bazel already has a schema: mandatory, allow_single_file, values, providers, and executable tool attrs. Use fail() for rule-specific analysis-time invariants, provider init for reusable provider invariants, and _validation actions for checks that require artifact contents.

The best error is early, specific, and attached to the abstraction the user actually called.

Check your understanding · 4 questions

1.Which validation layer should reject a dependency that lacks a provider your rule consumes?

Select one answer

2.Match each validation layer to the kind of information it can check.

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

Answers
attr.* schema
fail() in the implementation
provider(init = ...)
_validation action

3.Which statements describe good fail() usage in a rule implementation?

Select all that apply

4.Which statements describe validation-action and error-test limits for custom rules?

Select all that apply

0 of 4 answered

Footnotes

  1. Rules — rule authoring lifecycle: attrs, implementation functions, providers, actions, and validation actions.

  2. All Bazel filesfail() is the Starlark built-in for causing evaluation to fail with an error.

  3. Rules — rules cannot read artifacts during analysis. Artifact-content checks must run as actions.

  4. .bzl filesrule() signature and attrs parameter.

  5. attrmandatory, allow_single_file, values, providers, executable, and cfg parameters.

  6. All Bazel filesfail() causes execution to fail with an error.

  7. All Bazel files — positional args, sep, and stack_trace parameters for fail().

  8. Rules — implementation functions read attrs/files/providers during analysis, but file contents belong to actions.

  9. Rules — custom provider initialization for preprocessing and validation.

  10. .bzl filesprovider(init = ...) returns a provider symbol plus raw constructor and describes validation failure behavior.

  11. Rules — validation actions and the _validation output group.

  12. Rules — validation outputs should stay out of DefaultInfo and ordinary action inputs.

  13. Rules_validation outputs are always requested while normal caching and incrementality still apply.

  14. Rules — cases where validation actions are not run.

  15. Testing — analysis tests check rule actions and providers.

  16. Rules — test that validation outputs are not added to other action inputs.

  17. Testing — analysis test assertions should report through the test result instead of raw fail().