4.1.4 Rule Finalizers
extraRule finalizers are symbolic macros for the rare case where package-level policy needs to inspect the whole BUILD file. They are still macros, not rules, but Bazel evaluates them after all non-finalizer targets in the package have been declared, so their result no longer depends on being written last in the file.1
If regular 4.1.3 Symbolic Macros (Bazel 8+) are about making target families safer and more structured, finalizers are about replacing one specific legacy pattern: the "epilogue macro" that calls native.existing_rules() at the bottom of a BUILD file. Use them when the package needs a final policy pass, not as a general-purpose way to make macros smarter.
The Problem They Fix
Before finalizers, repositories sometimes used a legacy macro that inspected targets already declared in the current package. A macro like that could enforce naming conventions, inspect tags, check dependencies, or generate package-wide tests from the targets it found.2 The catch was ordering: native.existing_rules() could only see the targets created so far, so the macro had to be placed at the end of the BUILD file.
That ordering rule is fragile. It turns a semantic requirement into a style convention that has to be maintained by reviewers, formatters, presubmit checks, or comments. It also gets awkward when a package wants more than one such final pass: which macro goes last, and what should each one see?3
A finalizer moves that ordering rule into Bazel's loading semantics. The macro call may appear anywhere in the file, but Bazel evaluates it in the final stage of package loading, after all non-finalizer targets have been defined.4
What finalizer = True Changes
A finalizer is declared with the same macro() API as any other symbolic macro, plus finalizer = True.5
# tools/policy.bzl
def _no_forbidden_tag_impl(name, visibility, forbidden_tag):
for target in native.existing_rules().values():
if forbidden_tag in target.get("tags", ()):
fail("%s has forbidden tag %r" % (target["name"], forbidden_tag))
no_forbidden_tag = macro(
attrs = {
"forbidden_tag": attr.string(default = "manual", configurable = False),
},
implementation = _no_forbidden_tag_impl,
finalizer = True,
)
The implementation still takes name and visibility, because it is a symbolic macro implementation. The special part is that it may call native.existing_rules() or native.existing_rule(), which ordinary symbolic macros may not do.6
The call site does not need to be last:
# app/BUILD.bazel
load("//tools:policy.bzl", "no_forbidden_tag")
cc_library(
name = "core",
srcs = ["core.cc"],
)
no_forbidden_tag(name = "package_policy")
cc_test(
name = "core_test",
srcs = ["core_test.cc"],
deps = [":core"],
)
When package_policy runs, native.existing_rules() sees both core and core_test, even though core_test appears later in the file. It does not see targets declared by this or any other finalizer.7 That detail is what makes multiple finalizers more predictable: each finalizer inspects the same non-finalizer package surface, instead of accidentally depending on another finalizer's generated targets.
What The Finalizer Can See
native.existing_rules() returns a dict-like object keyed by target name. Each value describes a rule target's visible attributes, including name and kind. Private attributes and some unrepresentable legacy attribute types are excluded.8 Labels are converted to strings, lists become tuples, dicts are converted, and computed defaults that have not been specified are not included because they are not available until analysis.9
That makes finalizers useful for policy over declarations, not for analysis-time reasoning. A finalizer can ask questions like:
- Does every target with a certain tag have a matching test?
- Are package-local targets using a forbidden tag?
- Do generated test suites need to include all targets matching a naming convention?
- Are package conventions expressed consistently enough to fail fast during loading?
It should not try to behave like a custom rule. It cannot read providers, inspect configured attribute values, or understand toolchain resolution. Those belong in analysis-phase rule APIs, which are introduced in 4.2 Custom Rules, Providers & Actions.
Introspection Is Not Visibility
Finalizers have one subtle visibility rule that prevents a common over-reading of existing_rules(). A finalizer may be able to introspect a target's attributes and still be unable to depend on that target. Targets declared by a finalizer can also see targets visible to the finalizer target's package, but a target can be introspectable through native.existing_rules() without being usable as a dependency under the visibility system.10
In practice, keep those concerns separate. Use finalizers to observe the package surface and enforce declaration-level policy. If the finalizer also declares helper targets, their dependencies still have to satisfy Bazel's normal visibility checks. The introspection API is not an escape hatch around macro-private targets.
The Cost: Full Package Knowledge
The reason finalizers are powerful is also the reason they are niche. A finalizer needs the complete set of non-finalizer targets in the package. That requirement conflicts with the long-term performance goal behind symbolic macros: lazy expansion, where Bazel can avoid expanding unrelated macros when a requested target does not need them.11
This is a context-dependent trade-off. A CI policy pass may want finalizers expanded because the goal is to validate the package as a whole. An IDE editing loop may prefer lower latency and avoid work that is not needed for the target under inspection.12 That is why finalizers should not become the default abstraction for everyday macro authoring.
Use a finalizer when all three conditions hold:
- The policy is package-wide.
- The policy needs to inspect targets independent of their lexical order.
- The cost of forcing full non-finalizer expansion is acceptable for the workflow where the finalizer runs.
If the policy applies to one target family, put it in the symbolic macro that creates that family. If it needs providers, actions, configured attributes, or toolchains, write a rule. If it is only a legacy convenience wrapper, keep it in 4.1.2 Legacy Macros until symbolic macros solve a concrete problem.
Rule finalizers are the disciplined replacement for order-sensitive epilogue macros. They let a symbolic macro inspect all non-finalizer targets in the current package without depending on where the call appears in the BUILD file.
Treat that power as a package-level policy tool. It is useful in CI and migration checks, but it trades away the main performance promise of ordinary symbolic macros: avoiding expansion of work that the current request does not need.
Check your understanding · 4 questions
1.What problem do rule finalizers primarily solve?
Select one answer
2.Which statements about native.existing_rules() in a finalizer are correct?
Select all that apply
3.A finalizer sees lib_private in native.existing_rules() and wants to create a helper target that depends on it. What does that introspection prove?
Select one answer
4.True or false: rule finalizer trade-offs
Choose True or False for each sentence
Footnotes
-
Macros — finalizers are evaluated after non-finalizer targets in package loading and may call
native.existing_rules(). ↩ -
Symbolic Macros and Rule Finalizers - Susan Steinman & Alexandre Rostovtsev, Google — epilogue macro use cases: policies, target inspection, and generated tests. ↩
-
Symbolic Macros and Rule Finalizers - Susan Steinman & Alexandre Rostovtsev, Google — ordering and multiple-epilogue-macro problems. ↩
-
Macros — finalizers are position-independent and run in the final stage of package loading. ↩
-
.bzl files —
macro()parameter reference forfinalizer. ↩ -
Macros — symbolic macro restrictions and finalizer exception for
native.existing_rules(). ↩ -
.bzl files — finalizers query non-finalizer targets and cannot access targets declared by any finalizer. ↩
-
native — attribute value conversion and computed-default exclusion in
existing_rule(). ↩ -
Visibility — finalizer-specific visibility and introspection caveat. ↩
-
Macros — symbolic macros are designed for lazy evaluation, while finalizers require whole-package knowledge. ↩
-
Symbolic Macros and Rule Finalizers - Susan Steinman & Alexandre Rostovtsev, Google — finalizers, lazy expansion trade-off, and CI versus IDE examples. ↩