4.1.3 Symbolic Macros (Bazel 8+)

recommended

Symbolic 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

A symbolic macro draws a boundary around generated targets
macro() defines the typed schema. The BUILD call supplies values. During loading, the macro expands target declarations.
app/BUILD.bazel caller
BUILD call
miniature(
name = "logo",
src = "logo.png",
size = "64x64",
visibility = ["//app:__pkg__"],
)
Label resolution

Typed label strings resolve from the caller's package.

tools/images/defs.bzl macro()
Typed API
miniature = macro(
implementation = _impl,
attrs = {
"src": attr.label(...),
"size": attr.string(...),
},
)
Implementation
def _impl(name, visibility, src, size):
native.filegroup(
name = name + "_files")
native.genrule(
name = name,
visibility = visibility)
Generated targets
Only names in the logo namespace can be built or used
valid names: name, name_*, name-*, name.*
macro instance: miniature(name = "logo")
:logo exported
:logo_files macro-private
:thumbnail may be declared, but cannot be built or used
A symbolic macro is still loading-phase code, but its declared boundary lets Bazel validate attrs, identify the logo target namespace, export :logo, and hide :logo_files.

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

Inspect the typed attribute contract and private helper naming in the runnable glyph_app symbolic macro.

key takeaway

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

Symbolic macros are available by default starting in Bazel 8.
A symbolic macro implementation must accept name and visibility parameters.
A target created inside a symbolic macro is exported to the caller automatically even if the macro does not pass visibility = visibility.
A configurable symbolic macro attribute may wrap a plain value in a trivial select().
Use configurable = False when the macro must inspect an attribute value during loading.
0 of 4 answered

Footnotes

  1. Macros — symbolic macros are available by default in Bazel 8, with typed arguments, visibility control, and a design aimed at lazy evaluation.

  2. .bzl filesmacro() return value, implementation, attrs, name, visibility, and global assignment requirements.

  3. Creating a Symbolic Macro — symbolic macro tutorial wrapping native.genrule() and recommending rules for more complex language/tool support.

  4. Macrosattrs dictionary and implicit name / visibility attributes.

  5. Macros — symbolic macro typed arguments and caller-context string-to-label conversion.

  6. Symbolic Macros 2-pager (Design Document) — symbolic macro typed attributes, caller-context label conversion, and native.package_relative_label() motivation.

  7. Macrosinherit_attrs, public attribute inheritance, overriding, and removing inherited attrs with None.

  8. .bzl files — inherited non-mandatory attrs default to None to preserve omitted-attribute behavior.

  9. Macros — target naming convention for symbolic macro-created targets.

  10. .bzl files — targets violating the naming scheme may be declared but cannot be built, configured, or depended upon.

  11. Symbolic Macros and Rule Finalizers - Susan Steinman & Alexandre Rostovtsev, Google — naming schema enables lazy macro expansion by identifying the responsible macro from target names.

  12. Macros — symbolic macro visibility is checked based on the declaring macro, while legacy macros are transparent.

  13. Visibility — targets declared in symbolic macros default to private visibility, independent of package default_visibility.

  14. Symbolic Macros and Rule Finalizers - Susan Steinman & Alexandre Rostovtsev, Google — tools used by symbolic macros only need visibility to the macro definition package.

  15. Visibility — visibility delegation through labels passed to submacros.

  16. Macros — configurable symbolic macro attributes wrap plain non-None values in trivial select() expressions.

  17. Creating a Symbolic Macrosrc is non-configurable because the macro uses the source file name to construct an output filename.

  18. Symbolic Macros 2-pager (Design Document) — boolean macro logic should reject selectable values when the macro needs loading-phase decisions.

  19. Macros — generated rule targets reverse trivial select() wrapping when values are stored on the rule target.

  20. Macros — symbolic macro restrictions and finalizer exception for native.existing_rules().

  21. .bzl files — symbolic macro implementation restrictions including undeclared files and unavailable APIs.

  22. Symbolic Macros 2-pager (Design Document) — restrictions prevent side effects between macros and support lazy evaluation.

  23. Macros — migration troubleshooting for glob(), unsupported parameter shapes, and naming-schema issues.