A Bazel aspect is often described as a way to run extra logic over a target graph. That definition is correct, but it leaves out the decision that matters when writing build extensions: why use an aspect instead of defining another rule?
The distinction becomes clearer when the build graph already exists. A rule creates a target and defines what that target means. An aspect attaches another analysis to existing targets and can propagate along selected dependency attributes. I think of a rule as owning a node, while an aspect supplies a second view of nodes that are already there.
That second view is useful for dependency reports, IDE metadata, linting, code generation, instrumentation, and other cross-cutting work. It is also easy to overuse. If the output is a first-class product that developers should name in a BUILD file, a rule is usually the more honest abstraction.
The short version: aspect versus rule
- A rule defines a target, owns its attributes, registers actions, and returns providers to its reverse dependencies.
- An aspect is applied to an existing target and optionally propagates through attributes such as
deps. - A rule is normally instantiated explicitly in a
BUILDfile. - A command-line aspect can be attached without changing the target’s
BUILDdeclaration. - A rule’s providers are created before aspects run. An aspect can add its own providers, but it does not rewrite the rule’s providers.
Both have Starlark implementation functions. Both can inspect providers, register actions, and return providers. The important difference is how Bazel constructs the configured target graph around them.
A mental model that survives real build graphs
Suppose //app:server depends on //lib:rpc, which depends on //lib:transport. Building the server normally analyzes those three configured targets according to their rules.
//app:server
|
+-- deps --> //lib:rpc
|
+-- deps --> //lib:transport
Apply an aspect that propagates through deps, and Bazel creates aspect applications over that graph. Conceptually, if the aspect is named A, Bazel analyzes A(server), A(rpc), and A(transport). Inside A(server), the value reached through the propagated deps attribute includes the aspect results for its dependencies.
This is the part that a wrapper rule does not get for free. A rule can depend on //app:server and inspect providers exposed by that target, but it cannot automatically reinterpret every transitive target unless the necessary information was already propagated in providers or the rule attaches an aspect to its attribute.
Build a small source-inventory aspect
The following example collects source files from C++ targets. The provider carries a transitive depset, while an output group exposes one report file per aspect application. It is intentionally small enough to show the mechanics rather than hide them behind a production ruleset.
# tools/source_inventory.bzl
SourceInventoryInfo = provider(
fields = {
"files": "Transitive depset of source files.",
},
)
def _source_inventory_impl(target, ctx):
direct_files = []
transitive_files = []
if hasattr(ctx.rule.attr, "srcs"):
for src_target in ctx.rule.attr.srcs:
direct_files.extend(src_target.files.to_list())
if hasattr(ctx.rule.attr, "deps"):
for dep in ctx.rule.attr.deps:
if SourceInventoryInfo in dep:
transitive_files.append(
dep[SourceInventoryInfo].files,
)
all_files = depset(
direct = direct_files,
transitive = transitive_files,
)
report = ctx.actions.declare_file(
ctx.label.name + ".source_inventory.txt",
)
ctx.actions.write(
output = report,
content = "\n".join([
f.path for f in all_files.to_list()
]) + "\n",
)
return [
SourceInventoryInfo(files = all_files),
OutputGroupInfo(
source_inventory = depset([report]),
),
]
source_inventory_aspect = aspect(
implementation = _source_inventory_impl,
attr_aspects = ["deps"],
required_providers = [CcInfo],
)
The implementation receives two arguments. target exposes providers from the rule being visited. ctx exposes the rule’s attributes through ctx.rule.attr and lets the aspect declare actions and outputs.
The example uses hasattr because not every rule has srcs or deps. That detail matters in mixed repositories. The graph may contain aliases, generated targets, tests, external dependencies, and language-specific rules. An aspect written as though every visited node were a cc_library tends to fail far from the target that started the traversal.
What attr_aspects actually controls
attr_aspects = ["deps"] tells Bazel to propagate the aspect through attributes named deps. It does not mean “visit every dependency-like relationship.” If a ruleset uses attributes such as runtime_deps, exports, or a private tool attribute, those edges are not followed unless the aspect declares the appropriate propagation.
You may see attr_aspects = ["*"] in examples. That is convenient for exploration, but I would not make it the default in a large repository. Following every eligible attribute can pull toolchains, plugins, generated helpers, and implementation details into work that was supposed to describe application dependencies. Start with the edges that match the meaning of the analysis.
Limit where the aspect applies
required_providers = [CcInfo] limits the aspect to rules that advertise the required provider set. This is more robust than deciding that a target is C++ because its rule name starts with cc_. Providers describe capabilities; rule names describe conventions.
There is a subtlety worth remembering: provider constraints depend on what a rule advertises, not simply what you expect to find from its label or name. When an aspect unexpectedly skips a target, inspect the rule’s declared providers before changing propagation or adding special cases.
Run the aspect from the command line
bazel build //app:server \
--aspects=//tools:source_inventory.bzl%source_inventory_aspect \
--output_groups=source_inventory
The --aspects flag identifies the .bzl file and exported aspect symbol. The --output_groups flag requests the files placed in the aspect’s source_inventory output group.
An aspect generally should not return DefaultInfo. OutputGroupInfo is the normal way to make aspect-generated files requestable without pretending they are the target’s default outputs. Bazel can merge output groups from a rule and an aspect when the group names do not conflict.
How the same idea looks as a rule
A custom rule gives users a named target and explicit attributes:
# tools/source_report.bzl
def _source_report_impl(ctx):
files = ctx.attr.target[SourceInventoryInfo].files
output = ctx.actions.declare_file(ctx.label.name + ".txt")
ctx.actions.write(
output = output,
content = "\n".join([
f.path for f in files.to_list()
]) + "\n",
)
return [DefaultInfo(files = depset([output]))]
source_report = rule(
implementation = _source_report_impl,
attrs = {
"target": attr.label(
aspects = [source_inventory_aspect],
providers = [[CcInfo]],
mandatory = True,
),
},
)
# BUILD.bazel
load("//tools:source_report.bzl", "source_report")
source_report(
name = "server_sources",
target = "//app:server",
)
This is not an argument for replacing the aspect. The wrapper rule and the aspect solve different parts of the problem. The aspect traverses existing targets and accumulates transitive information. The rule gives the final report a stable label, owns the aggregate output, and makes the operation discoverable in the BUILD graph.
Rule and aspect differences that matter
Who creates the target?
A rule invocation creates a target with its own label. A command-line aspect creates no new user-facing target label. It is applied to an existing configured target.
Who controls the attributes?
A rule defines its public API through attrs. An aspect sees attributes belonging to the underlying rule through ctx.rule.attr. An aspect can define its own parameters and private tool attributes, but it does not redesign the target it visits.
How does transitive traversal happen?
A rule sees the direct dependencies declared in its attributes and the providers those dependencies expose. An aspect can be propagated automatically over selected attributes, creating aspect applications across the graph.
What outputs should they own?
A rule commonly returns its primary artifacts in DefaultInfo. An aspect normally exposes optional artifacts through OutputGroupInfo or returns analysis providers for another aspect or rule to consume.
Can an aspect change the target?
No. Providers produced by the underlying rule are already fixed when the aspect runs. The aspect application combines the rule’s providers with providers returned by the aspect, subject to provider conflicts. It is a layer of analysis, not a mutation hook.
When I would choose an aspect
- The analysis must work across existing rule types without asking every package owner to add a new target.
- The result depends on walking specific graph edges and accumulating transitive providers.
- The feature is tooling-oriented: IDE data, dependency metadata, lint inputs, instrumentation, or optional generated reports.
- The work should be enabled from the command line or attached to another rule’s attribute.
When I would choose a rule
- The output is a first-class build product that should have a stable label.
- Developers need an explicit, documented set of attributes in
BUILDfiles. - The target owns compilation, packaging, linking, generation, or another primary action.
- Other targets should depend directly on its outputs or providers as part of the normal build model.
The combined pattern is often the cleanest: use an aspect for graph-wide collection, then use a small rule to request the aspect and produce a stable aggregate artifact. That prevents BUILD files from mirroring an existing dependency graph just to run analysis over it.
Common mistakes
- Using
attr_aspects = ["*"]before deciding which dependency edges have semantic meaning. - Assuming every target has
srcsanddeps. - Returning large ordinary lists instead of transitive
depsetstructures. - Generating outputs but forgetting to expose and request an output group.
- Trying to replace or modify providers returned by the underlying rule.
- Choosing an aspect for a product that should simply be a named rule target.
Conclusion
Bazel aspects are best understood as graph-propagated analysis attached to targets that already exist. Rules define the build’s nouns; aspects provide another way to examine or augment those nouns without requiring a parallel set of BUILD declarations.
Start with the ownership question. If the extension creates a primary artifact with a meaningful label, write a rule. If it needs to inspect or enrich many existing targets along selected dependency edges, write an aspect. If it needs both graph traversal and a stable output target, use the two together.
For the exact API behavior, see Bazel’s official Aspects guide, Rules guide, and aspect() reference. For the surrounding project structure, continue with Bazel Workspace and BUILD Files or compare the larger build-system tradeoffs in Bazel vs CMake.


Leave a Reply