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

Choose macro or rule by the phase boundary
Start with an existing rule, then add only the smallest abstraction the contract needs.
Decision question
Does the abstraction only arrange existing targets?
If yes, stay in loading. If no, move to analysis.
yes: BUILD shape
no: target contract
Macro
Shape existing BUILD declarations
loading phase
Use for
+ Wrapping or stamping out existing rule calls
+ Forwarding common attributes and conventions
+ Naming or hiding helper targets around a public API
macro(...) -> native.genrule(...) or another existing rule
Rule
Define a new target contract
analysis phase
Use for
+ Returning providers for downstream targets
+ Declaring outputs and registering actions
+ Using toolchains, runfiles, or configured attributes
ctx.actions.run(...) + providers
Start with an existing rule. A macro may declare targets, but it cannot read providers. a rule may analyze providers and create actions but cannot declare targets.

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.

think

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:

NeedWhy a rule
Custom outputsRules declare files and directories with ctx.actions.declare_file() or related APIs, then return them through DefaultInfo or output groups.10
Custom actionsRules register actions with ctx.actions.run(), run_shell(), write(), or expand_template(). Macros can only call rules that already do this.11
ProvidersRules return providers and read providers from direct dependencies, which is how target kinds communicate semantic data across the graph.12
Toolchains or exec configurationRules 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 contractRules can define runnable targets, test targets, and runfiles in one analysis-phase contract.14
Output-dependent behaviorIf 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:

  1. Use an existing rule directly when one BUILD call is clear enough.
  2. Wrap it in a legacy macro when repetition or convention deserves one project-level API.
  3. Use a symbolic macro when typed attributes, inherited attributes, naming constraints, or private helper targets matter.
  4. 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.

key takeaway

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

Legacy macros are still appropriate for simple wrappers and compatibility.
A macro can inspect the resolved value of a configurable select() before deciding which provider a target should return.
Symbolic macros are useful when typed attributes, inherited attributes, naming constraints, or macro-private visibility matter.
Rules can instantiate other rules during analysis to create extra targets.
0 of 3 answered

Footnotes

  1. Extension Overview — macros vs rules and the loading/analysis/execution split.

  2. Legacy Macros — legacy macros create targets and no longer exist as macros after loading.

  3. Extension Overview — macros are useful when BUILD files become repetitive or complex.

  4. Rules — rule implementation functions run during analysis, register actions, and return providers.

  5. Symbolic Macros 2-pager (Design Document) — concise boundary between symbolic macros and rules.

  6. Macros — macros encapsulate and reuse existing rules and other macros.

  7. Creating a Symbolic Macro — symbolic macro tutorial wrapping native.genrule() for a simple tool task.

  8. Macros — symbolic macro attributes, inheritance, select() wrapping, and visibility semantics.

  9. Legacy Macros — legacy macro transparency and loading-phase behavior.

  10. Rules — declaring outputs and returning default outputs.

  11. Rules — action APIs such as ctx.actions.run(), write(), and expand_template().

  12. Rules — providers as inter-rule communication and transitive data.

  13. Rules — private tool attributes and cfg = "exec" for build-time tools.

  14. Rules — executable rules, test rules, and runfiles.

  15. What's better than a genrule? — reasons to write a custom rule instead of a genrule/macro wrapper.

  16. Migrating to Bazel symbolic macros — configurable attributes and why macros cannot inspect resolved select() values like rules can.

  17. Macros — symbolic macro naming restrictions and macro-private visibility.

  18. What's better than a genrule? — macro/genrule-first guidance and escalation criteria for custom rules.