4.1.1 Macro vs Rule Decision Framework
The macro-vs-rule decision is not about which abstraction feels more "Bazel-native." It is about which build phase should carry the behavior. A macro is a loading-phase convenience for declaring existing targets. A rule is an analysis-phase contract that creates providers, actions, outputs, runfiles, and toolchain-aware behavior.1
Start With The Phase Boundary
Macros run while Bazel reads BUILD files. They call rules or other macros, and by the end of loading Bazel has concrete targets to analyze.2 That makes macros a good fit when the shape of the build graph is already expressible with existing rules, but the BUILD files have become repetitive or hard to read.3
Rules run later. A rule implementation receives ctx during analysis, inspects configured attributes and providers from direct dependencies, declares outputs, registers actions, and returns providers.4 That is a different kind of power: a rule can create a new target kind with a real contract for downstream rules.
The shortest useful distinction is the one from the symbolic macros design: symbolic macros cannot have their own dependencies or propagate providers, while rules cannot instantiate other rules.5 If you need to stamp out several existing targets, you are in macro territory. If you need a target to communicate new semantic information to its consumers, you are in rule territory.
Decide: A team has a neat symbolic macro around cc_library. Now they want downstream rules to consume a custom provider from that abstraction and they want the abstraction to register one generated output. Can the macro grow into that role if its attrs are typed enough?
Reveal
No. Typed attrs make the BUILD-file API safer, but they do not move the abstraction into analysis. A symbolic macro still expands declarations while packages are loading. It can create targets that later produce providers or actions, but the macro itself cannot return providers, inspect configured deps, register actions, or own toolchain resolution.
That request crossed the phase line. Keep the macro if the job is "make these declarations pleasant and consistent." Write a custom rule when the job is "this target has analyzed behavior other rules depend on." The same .bzl file can contain both, but the API contract should not pretend they are the same power.
Use A Macro For BUILD-File Shape
Use a macro when you want a better BUILD API over existing targets. Typical macro work includes wrapping a genrule, creating a small family of related targets, forwarding common attributes, or encoding a project convention once instead of repeating it in every package.6
load("//tools/images:defs.bzl", "image_asset")
image_asset(
name = "logo",
src = "logo.png",
size = "100x100",
)
That macro can expand to an existing rule such as native.genrule() or a better tool-running rule, then let ordinary targets depend on :logo.7 The macro has made the call site smaller and more intentional, but it has not changed what Bazel can fundamentally model. Bazel still sees generated rule targets after loading.
Choose the macro style by constraints, not by novelty. Legacy macros are still appropriate for simple wrappers and compatibility. Symbolic macros are worth the extra structure when typed attributes, inherited attributes, automatic label and select() handling, naming constraints, or macro-private visibility solve a real problem.8,9
Use A Rule For A New Target Contract
Write a rule when the abstraction needs to participate in analysis, not just shorten declarations. The common signals are concrete:
| Need | Why a rule |
|---|---|
| Custom outputs | Rules declare files and directories with ctx.actions.declare_file() or related APIs, then return them through DefaultInfo or output groups.10 |
| Custom actions | Rules register actions with ctx.actions.run(), run_shell(), write(), or expand_template(). Macros can only call rules that already do this.11 |
| Providers | Rules return providers and read providers from direct dependencies, which is how target kinds communicate semantic data across the graph.12 |
| Toolchains or exec configuration | Rules can model build-time tools with private attributes and cfg = "exec". Full toolchain resolution belongs in 4.6 Toolchains & Platform Resolution.13 |
| Executable/test/runfiles contract | Rules can define runnable targets, test targets, and runfiles in one analysis-phase contract.14 |
| Output-dependent behavior | If the actions to run depend on requested outputs, that is a reason to graduate beyond macro/genrule wrapping.15 |
This is why language support is usually a ruleset, not a pile of macros. A language rule has to gather transitive compile data, choose tools, register compiler actions, expose providers such as compilation info, and make downstream targets understand what was produced.
The Escalation Ladder
Start as low as the problem allows:
- Use an existing rule directly when one
BUILDcall is clear enough. - Wrap it in a legacy macro when repetition or convention deserves one project-level API.
- Use a symbolic macro when typed attributes, inherited attributes, naming constraints, or private helper targets matter.
- Move to a custom rule when the abstraction needs actions, providers, toolchains, custom outputs, runfiles, or analysis-time validation.
This ladder keeps the first version cheap while leaving an honest migration path. It also prevents the two common failures: writing a custom rule for simple boilerplate, or stretching a macro until it becomes a hidden rule without the APIs rules provide.
Watch For Macro Smells
A macro is still loading-phase code. If it starts branching on things Bazel only knows after configuration, it is probably in the wrong layer. select() values are especially revealing: in macros they are still opaque configurable expressions, while rules see resolved configured attributes during analysis.16 If the macro needs to inspect the selected value to decide what actions or providers exist, move that logic into a rule or push the condition down into attributes of existing rules.
String-building is another smell. A little naming convention is fine. Complex label rewriting, hidden internal target names, or long chains of generated targets are signs that users may start depending on implementation details. Symbolic macros reduce that risk with naming constraints and macro-private visibility, but they do not turn macro internals into providers or actions.17
Genrule wrappers deserve the same caution. A tool invocation with known inputs, outputs, and command-line flags is often enough, and the common guidance is to reach for genrule-like helpers plus a macro before writing a custom rule.18 But once the wrapper needs provider interop, persistent workers, toolchain resolution, or output-dependent action choices, the custom rule is no longer accidental complexity. It is the correct abstraction.
Compare both choices in one runnable file: glyph_legacy_app and glyph_app expand to the same target shape while exposing different loading-phase contracts.
Choose a macro when you are shaping how existing targets are declared. Choose a rule when you are defining what a new kind of target means.
The phase boundary is the guardrail: loading-phase macros create target declarations. Analysis-phase rules create the action graph and provider contract. 4.1.2 Legacy Macros and 4.1.3 Symbolic Macros (Bazel 8+) sharpen the macro side, and 4.2 Custom Rules, Providers & Actions takes over once you cross into rule authoring.
Check your understanding · 3 questions
1.A helper wraps existing rules to reduce repeated BUILD boilerplate. Which abstraction should you reach for first?
Select one answer
2.Which requirements are strong signals that a macro should become a custom rule?
Select all that apply
3.True or false: choosing between macro styles and rules.
Choose True or False for each sentence
select() before deciding which provider a target should return.Footnotes
-
Extension Overview — macros vs rules and the loading/analysis/execution split. ↩
-
Legacy Macros — legacy macros create targets and no longer exist as macros after loading. ↩
-
Extension Overview — macros are useful when BUILD files become repetitive or complex. ↩
-
Rules — rule implementation functions run during analysis, register actions, and return providers. ↩
-
Symbolic Macros 2-pager (Design Document) — concise boundary between symbolic macros and rules. ↩
-
Macros — macros encapsulate and reuse existing rules and other macros. ↩
-
Creating a Symbolic Macro — symbolic macro tutorial wrapping
native.genrule()for a simple tool task. ↩ -
Macros — symbolic macro attributes, inheritance,
select()wrapping, and visibility semantics. ↩ -
Legacy Macros — legacy macro transparency and loading-phase behavior. ↩
-
Rules — action APIs such as
ctx.actions.run(),write(), andexpand_template(). ↩ -
Rules — providers as inter-rule communication and transitive data. ↩
-
Rules — private tool attributes and
cfg = "exec"for build-time tools. ↩ -
What's better than a genrule? — reasons to write a custom rule instead of a genrule/macro wrapper. ↩
-
Migrating to Bazel symbolic macros — configurable attributes and why macros cannot inspect resolved
select()values like rules can. ↩ -
Macros — symbolic macro naming restrictions and macro-private visibility. ↩
-
What's better than a genrule? — macro/genrule-first guidance and escalation criteria for custom rules. ↩