4.1.3 Symbolic Macros (Bazel 8+)
recommendedSymbolic macros are the Bazel 8 macro form for BUILD-file APIs that Bazel can understand as structured declarations. They still run in the loading phase and still instantiate existing rules or macros, but their macro() declaration gives Bazel a typed attribute schema, a target namespace, macro-scoped visibility, and enough constraints to support future lazy expansion.1
Typed label strings resolve from the caller's package.
A Macro With A Declared Shape
A symbolic macro is defined by assigning the result of macro() to a global value in a .bzl file. The declaration names an implementation function and an attrs dictionary. The implementation function must accept at least name and visibility, then declares targets by calling rules or other macros.2
# //tools/images:defs.bzl
def _miniature_impl(name, visibility, src, size, **kwargs):
native.genrule(
name = name,
visibility = visibility,
srcs = [src],
outs = [name + "_small_" + src.name],
cmd = "convert $< -resize " + size + " $@",
**kwargs
)
miniature = macro(
doc = "Create a resized image.",
implementation = _miniature_impl,
inherit_attrs = native.genrule,
attrs = {
"src": attr.label(
allow_single_file = True,
configurable = False,
doc = "Image file.",
),
"size": attr.string(
default = "100x100",
configurable = False,
),
"srcs": None,
"cmd": None,
"outs": None,
},
)
The call site still looks like a rule-shaped BUILD declaration:
load("//tools/images:defs.bzl", "miniature")
miniature(
name = "logo",
src = "logo.png",
tags = ["manual"],
)
This example wraps native.genrule(), so it remains macro territory: the macro creates an existing rule target with a clearer project-level API. If the abstraction needs custom providers, action registration, toolchain resolution, or analyzed dependency data, it belongs in 4.2 Custom Rules, Providers & Actions, not in a macro.3
Attributes Are An API, Not Just Parameters
Legacy macros receive ordinary Starlark parameters. Symbolic macros receive declared attributes. name and visibility are implicit, while user-facing parameters are listed in attrs with types such as attr.label, attr.label_list, attr.string, or attr.bool.4 That makes the macro API visible to documentation and tooling, and it lets Bazel validate values before the macro body silently builds the wrong target family.
Label attributes are especially important. If the caller passes a string where a label is expected, Bazel converts it using the caller's package context.5 That preserves the normal BUILD-file behavior for caller-supplied inputs while removing a class of legacy macro bugs around Label() versus native.package_relative_label().6
inherit_attrs is the ergonomic bridge for wrapper macros. A macro can inherit public attributes from a rule, another macro, or "common", override selected attributes, and remove inherited ones by setting them to None.7 When a non-mandatory inherited attribute is absent, its value is None in the outer macro, even if the wrapped rule has a different apparent default. That is deliberate: forwarding None behaves like omitting the attribute, which preserves computed defaults and query output semantics.8
The Name Is A Namespace
Targets created by a symbolic macro must either use the macro's name exactly, or start with that name followed by _, -, or .. A call such as miniature(name = "logo") can create logo, logo_small, logo-test, or logo.preview, but not thumbnail.9
This is more than style. The naming rule tells Bazel which macro instance is responsible for a target label. Targets or files that violate the naming convention may be declared, but cannot be built or used as dependencies.10 The same convention is the foundation for lazy macro expansion: Bazel can eventually skip unrelated symbolic macros when the requested target's name clearly belongs to another namespace.11
Legacy macro authors often used the same naming convention voluntarily. Symbolic macros turn it into an enforceable contract. That protects neighboring target names in a package and makes generated helper names less likely to become accidental public APIs.
Visibility Is Scoped To The Macro
Symbolic macro visibility is the biggest behavioral difference from 4.1.2 Legacy Macros. Legacy macros are transparent to the visibility system, so //visibility:private on a helper target still leaves it visible to other targets in the same package. Symbolic macros change the "location" used by visibility checks: a target declared inside the macro is checked as belonging to the macro definition context, not merely the caller's package.12
By default, a target declared inside a symbolic macro is internal to the macro. The caller's package-level default_visibility does not apply inside the macro.13 To export a target as part of the macro's API, pass the macro implementation's visibility parameter through to the target:
def _bundle_impl(name, visibility, srcs):
native.filegroup(
name = name + "_internal_files",
srcs = srcs,
)
native.genrule(
name = name,
srcs = [name + "_internal_files"],
outs = [name + ".txt"],
cmd = "cat $(SRCS) > $@",
visibility = visibility,
)
Here :bundle_internal_files is an implementation detail, while :bundle is exported according to the macro call's visibility. This connects back to the target-visibility basics from 0.2.4 Visibility, but the unit of encapsulation is now the macro API rather than only the package.
The same rule helps tools. A tool used by the macro body only needs to be visible to the package that defines the macro, not to every package that might call it.14 When macros call submacros, Bazel also has a delegation mechanism for labels passed through label-typed attributes, so composed macros can preserve visibility privileges without exposing helper targets to callers.15
select() Must Be A Design Choice
Symbolic macro attributes are configurable by default when their type supports it. If a caller passes a plain value to a configurable attribute, the macro implementation sees it as a trivial select({"//conditions:default": ...}).16 This is intentionally annoying in the right place: if the macro tries to index, branch on, or mutate a configurable value as if it were an ordinary list or bool, it fails while the macro author can still fix the API.
Use configurable = False when the macro must inspect the value during loading. The image example marks src non-configurable because the macro uses src.name to construct an output filename, and marks size non-configurable because the macro concatenates it into the generated command string. A select() cannot produce either one loading-phase value.17 A boolean such as create_test often deserves the same treatment, because a macro cannot know which branch of a select() will be chosen later during analysis.18
Forwarding configurable values is different. If the macro only passes deps, srcs, or copts through to a generated rule, keeping the attribute configurable is usually correct: the rule target will receive the configurable expression and Bazel will resolve it in analysis.19 The boundary is the same phase boundary from 4.1.1 Macro vs Rule Decision Framework. A macro may shape declarations around the syntax it receives. It may not inspect configured target values, providers, or toolchain results.
Restrictions Are Part Of The Feature
Symbolic macros are deliberately less free-form than legacy macros. They may not return values, mutate their arguments, call native.package(), call native.glob(), call native.environment_group(), or call native.existing_rules() unless they are declared as rule finalizers.20 They also cannot refer to undeclared input files that were not passed through an argument.21
Those restrictions are not arbitrary loss of power. They prevent side effects between macros, keep BUILD files readable for tools, and make lazy evaluation possible.22 If a legacy macro depends on glob() or native.existing_rules(), migrate the shape carefully: pass the glob() result from the BUILD file, keep a small legacy wrapper where compatibility requires it, or use 4.1.4 Rule Finalizers for the package-wide epilogue pattern.23
Migration Is A Design Review
Do not migrate every legacy macro just because Bazel 8 supports symbolic macros. Symbolic macros are worth it when the macro's public surface benefits from typed attrs, inherited rule attrs, enforced target names, private helper targets, or cleaner select() behavior. They are less compelling for a tiny compatibility wrapper whose current behavior depends on arbitrary Starlark data or caller-side glob() logic.
The migration path in M5 Legacy → Symbolic Macros goes deeper into naming constraints, visibility differences, default handling, mutability, configurable attributes, and Buildozer-assisted refactors. This article's rule of thumb is narrower: use symbolic macros when Bazel understanding the macro boundary gives you something concrete.
Inspect the typed attribute contract and private helper naming in the runnable glyph_app symbolic macro.
Symbolic macros are still macros: they create target declarations during loading and do not return providers, actions, or configured dependency data.
Their value is the boundary around that loading-phase work. A typed attribute schema defines the API, the name prefix defines the target namespace, macro-scoped visibility hides implementation targets, and configurable forces the author to decide whether select() belongs in the macro logic or should pass through to a rule.
Check your understanding · 4 questions
1.In Bazel 8+ symbolic macros, inside m(name = "hello"), which created target name violates the naming rule and cannot be built or used as a dep?
Select one answer
2.A symbolic macro inherits public attrs from native.genrule and forwards them to a wrapped genrule. If the caller omits a non-mandatory inherited attr whose wrapped rule has a default, what should the macro do?
Select one answer
3.Which symbolic macro features make its loading-phase API easier for Bazel and tools to understand?
Select all that apply
4.True or false: symbolic macro attribute and visibility behavior.
Choose True or False for each sentence
name and visibility parameters.visibility = visibility.select().configurable = False when the macro must inspect an attribute value during loading.Footnotes
-
Macros — symbolic macros are available by default in Bazel 8, with typed arguments, visibility control, and a design aimed at lazy evaluation. ↩
-
.bzl files —
macro()return value,implementation,attrs,name,visibility, and global assignment requirements. ↩ -
Creating a Symbolic Macro — symbolic macro tutorial wrapping
native.genrule()and recommending rules for more complex language/tool support. ↩ -
Macros —
attrsdictionary and implicitname/visibilityattributes. ↩ -
Macros — symbolic macro typed arguments and caller-context string-to-label conversion. ↩
-
Symbolic Macros 2-pager (Design Document) — symbolic macro typed attributes, caller-context label conversion, and
native.package_relative_label()motivation. ↩ -
Macros —
inherit_attrs, public attribute inheritance, overriding, and removing inherited attrs withNone. ↩ -
.bzl files — inherited non-mandatory attrs default to
Noneto preserve omitted-attribute behavior. ↩ -
Macros — target naming convention for symbolic macro-created targets. ↩
-
.bzl files — targets violating the naming scheme may be declared but cannot be built, configured, or depended upon. ↩
-
Symbolic Macros and Rule Finalizers - Susan Steinman & Alexandre Rostovtsev, Google — naming schema enables lazy macro expansion by identifying the responsible macro from target names. ↩
-
Macros — symbolic macro visibility is checked based on the declaring macro, while legacy macros are transparent. ↩
-
Visibility — targets declared in symbolic macros default to private visibility, independent of package
default_visibility. ↩ -
Symbolic Macros and Rule Finalizers - Susan Steinman & Alexandre Rostovtsev, Google — tools used by symbolic macros only need visibility to the macro definition package. ↩
-
Visibility — visibility delegation through labels passed to submacros. ↩
-
Macros — configurable symbolic macro attributes wrap plain non-
Nonevalues in trivialselect()expressions. ↩ -
Creating a Symbolic Macro —
srcis non-configurable because the macro uses the source file name to construct an output filename. ↩ -
Symbolic Macros 2-pager (Design Document) — boolean macro logic should reject selectable values when the macro needs loading-phase decisions. ↩
-
Macros — generated rule targets reverse trivial
select()wrapping when values are stored on the rule target. ↩ -
Macros — symbolic macro restrictions and finalizer exception for
native.existing_rules(). ↩ -
.bzl files — symbolic macro implementation restrictions including undeclared files and unavailable APIs. ↩
-
Symbolic Macros 2-pager (Design Document) — restrictions prevent side effects between macros and support lazy evaluation. ↩
-
Macros — migration troubleshooting for
glob(), unsupported parameter shapes, and naming-schema issues. ↩