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
-
attr.* schemadeclarative shapeReject shape problemsLet Bazel validate the BUILD API before implementation logic runs.mandatory values providers allow_single_file
-
implementation fail()analysis invariantReject semantic problemsUse ctx to compare configured attrs and providers. A valid result may then register actions.ctx.attr ctx.files dep[Info] rule policy
-
provider(init = ...)interface boundaryReject provider contract problemsKeep reusable invariants with the provider other rules consume.nonempty fields normalized depset shared contract
-
ordinary execution actionexecution checkReject artifact-content problemsModel checks that need file contents as execution-phase actions. These checks can fail during execution.lint static analysis generated metadata style check
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
| Layer | Knows | Use it for |
|---|---|---|
attr.* schema | User-supplied attribute shape | required attrs, file extension filters, executable tools, allowed string values, required providers |
implementation fail() | configured attrs and direct dependency providers | cross-attribute invariants, semantic compatibility, rule policy |
provider(init = ...) | provider constructor arguments | reusable provider invariants and normalized provider construction |
_validation output group | source/generated artifact contents | lint, 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.
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
attr.* schemafail() in the implementationprovider(init = ...)_validation action3.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
Footnotes
-
Rules — rule authoring lifecycle: attrs, implementation functions, providers, actions, and validation actions. ↩
-
All Bazel files —
fail()is the Starlark built-in for causing evaluation to fail with an error. ↩ -
Rules — rules cannot read artifacts during analysis. Artifact-content checks must run as actions. ↩
-
.bzl files —
rule()signature andattrsparameter. ↩ -
attr —
mandatory,allow_single_file,values,providers,executable, andcfgparameters. ↩ -
All Bazel files —
fail()causes execution to fail with an error. ↩ -
All Bazel files — positional
args,sep, andstack_traceparameters forfail(). ↩ -
Rules — implementation functions read attrs/files/providers during analysis, but file contents belong to actions. ↩
-
Rules — custom provider initialization for preprocessing and validation. ↩
-
.bzl files —
provider(init = ...)returns a provider symbol plus raw constructor and describes validation failure behavior. ↩ -
Rules — validation actions and the
_validationoutput group. ↩ -
Rules — validation outputs should stay out of
DefaultInfoand ordinary action inputs. ↩ -
Rules —
_validationoutputs are always requested while normal caching and incrementality still apply. ↩ -
Testing — analysis tests check rule actions and providers. ↩
-
Rules — test that validation outputs are not added to other action inputs. ↩
-
Testing — analysis test assertions should report through the test result instead of raw
fail(). ↩