Tweag
News
Capabilities
Dropdown arrow
Careers
Research
Blog
Contact
Modus Create
News
Capabilities
Dropdown arrow
Careers
Research
Blog
Contact
Modus Create

Migrating to Bazel symbolic macros

20 November 2025 — by Alexey Tereshenkov

In Bazel, there are two types of macros: legacy macros and symbolic macros, that were introduced in Bazel 8. Symbolic macros are recommended for code clarity, where possible. They include enhancements like typed arguments and the ability to define and limit the visibility of the targets they create.

This post is intended for experienced Bazel engineers or those tasked with modernizing the build metadata of their codebases. The following discussion assumes a solid working knowledge of Bazel’s macro system and build file conventions. If you are looking to migrate legacy macros or deepen your understanding of symbolic macros, you’ll find practical guidance and nuanced pitfalls addressed here.


What are symbolic macros?

Macros instantiate rules by acting as templates that generate targets. As such, they are expanded in the loading phase, when Bazel definitions and BUILD files are loaded and evaluated. This is in contrast with build rules that are run later in the analysis phase. In older Bazel versions, macros were defined exclusively as Starlark functions (the form that is now called “legacy macros”). Symbolic macros are an improvement on that idea; they allow defining a set of attributes similar to those of build rules.

In a BUILD file, you invoke a symbolic macro by supplying attribute values as arguments. Because Bazel is explicitly aware of symbolic macros and their function in the build process, they can be considered “first-class macros”. See the Symbolic macros design document to learn more about the rationale. Symbolic macros also intend to support lazy evaluation, a feature that is currently being considered for a future Bazel release. When that functionality is implemented, Bazel would defer evaluating a macro until the targets defined by that macro are actually requested.

Conventions and restrictions

There is already good documentation that explains how to write symbolic macros. In this section, we are going to take a look at some practical examples of the restrictions that apply to their implementation, which you can learn more about in the Restrictions docs page.

Naming

Any targets created by a symbolic macro must either match the macro’s name parameter exactly or begin with that name followed by a _ (preferred), ., or -. This is different from legacy macros which don’t have naming constraints.

This symbolic macro

# defs.bzl
def _simple_macro_impl(name):
    native.genrule(
        name = "genrule" + name,
        outs = [name + "_out.data"],
        srcs = ["//:file.json"],
    )

# BUILD.bazel
simple_macro(name = "tool")

would fail when evaluated:

$ bazel cquery //...
ERROR: in genrule rule //src:genruletool: Target //src:genruletool declared in symbolic macro 'tool'
violates macro naming rules and cannot be built.

This means simple_macro(name = "tool") may only produce files or targets named tool or starting with tool_, tool., or tool-. In this particular macro, tool_genrule would work.

Access to undeclared resources

Symbolic macros must follow Bazel’s standard visibility rules: they cannot directly access source files unless those files are passed in as arguments or are made public by their parent package. This is different from legacy macros, whose implementations were effectively inlined into the BUILD file where they were called.

Attributes

Positional arguments

In legacy macro invocations, you could have passed the attribute values as positional arguments. For instance, these are perfectly valid legacy macro calls:

# defs.bzl
def special_test_legacy(name, tag = "", **kwargs):
    kwargs["name"] = name
    kwargs["tags"] = [tag] if tag else []
    cc_test(**kwargs)


# BUILD.bazel
special_test_legacy("no-tag")
special_test_legacy("with-tag", "manual")

With the macro’s name and tags collected as expected:

$ bazel cquery //test/package:no-tag --output=build
cc_test(
  name = "no-tag",
  tags = [],
  ...
)

$ bazel cquery //test/package:with-tag --output=build
cc_test(
  name = "with-tag",
  tags = ["manual"],
  ...
)

You can control how arguments are passed to functions by using an asterisk (*) in the parameter list of a legacy macro, as per the Starlark language specs. If you are a seasoned Python developer (Starlark’s syntax is heavily inspired by Python), you might have already guessed that this asterisk separates positional arguments from keyword-only arguments:

# defs.bzl
def special_test_legacy(name, *, tag = "", **kwargs):
    kwargs["name"] = name
    kwargs["tags"] = [tag] if tag else []
    cc_test(**kwargs)

# BUILD.bazel
special_test_legacy("no-tag")  # okay
special_test_legacy("with-tag", tag="manual") # okay
# Error: special_test_legacy() accepts no more than 1 positional argument but got 2
special_test_legacy("with-tag", "manual")

Positional arguments are not supported in symbolic macros as attributes must either be declared in the attrs dictionary (which would make it automatically a keyword argument) or be inherited in which case it should also be provided by name.

Arguably, avoiding positional arguments in macros altogether is helpful because it eliminates subtle bugs caused by incorrect order of parameters passed and makes them easier to read and easier to process by tooling such as buildozer.

Default values

Legacy macros accepted default values for their parameters which made it possible to skip passing certain arguments:

# defs.bzl
def special_test_legacy(name, *, purpose = "dev", **kwargs):
    kwargs["name"] = name
    kwargs["tags"] = [purpose]
    cc_test(**kwargs)

# BUILD.bazel
special_test_legacy("dev-test")
special_test_legacy("prod-test", purpose="prod")

With symbolic macros, the default values are declared in the attrs dictionary instead:

# defs.bzl
def _special_test_impl(name, purpose = "dev", **kwargs):
    kwargs["tags"] = [purpose]
    cc_test(
        name = name,
        **kwargs
    )

special_test = macro(
    inherit_attrs = native.cc_test,
    attrs = {
        "purpose": attr.string(configurable = False, default = "staging"),
        "copts": None,
    },
    implementation = _special_test_impl,
)

# BUILD.bazel
special_test(
    name = "my-special-test-prod",
    srcs = ["test.cc"],
    purpose = "prod",
)

special_test(
    name = "my-special-test-dev",
    srcs = ["test.cc"],
)

Let’s see what kind of tags are going to be set for these cc_test targets:

$ bazel cquery //test/package:my-special-test-prod --output=build
cc_test(
  name = "my-special-test-prod",
  tags = ["prod"],
  ...
)

$ bazel cquery //test/package:my-special-test-dev --output=build
cc_test(
  name = "my-special-test-dev",
  tags = ["staging"],
  ...
)

Notice how the default dev value declared in the macro implementation was never used. This is because the default values defined for parameters in the macro’s function are going to be ignored, so it’s best to remove them to avoid any confusion.

Also, all the inherited attributes have a default value of None, so make sure to refactor your macro logic accordingly. Be careful when processing the keyword arguments to avoid subtle bugs such as checking whether a user has passed [] in a keyword argument merely by doing if not kwargs["attr-name"] as None would also be evaluated to False in this context.

This might be potentially confusing as the default value for many common attributes is not None. Take a look at the target_compatible_with attribute which normally has the default value [] when used in a rule, but when used in a macro, would still by default be set to None. Using bazel cquery //:target --output=build with some print calls in your .bzl files can help when refactoring.

Inheritance

Macros are frequently designed to wrap a rule (or another macro), and the macro’s author typically aims to pass most of the wrapped symbol’s attributes using **kwargs directly to the macro’s primary target or the main inner macro without modification.

To enable this behavior, a macro can inherit attributes from a rule or another macro by providing the rule or macro symbol to the inherit_attrs parameter of macro(). Note that when inherit_attrs is set, the implementation function must have a **kwargs parameter. This makes it possible to avoid listing every attribute that the macro may accept, and it is also possible to disable certain attributes that you don’t want macro callers to provide. For instance, let’s say you don’t want copts to be defined in macros that wrap cc_test because you want to manage them internally within the macro body instead:

# BUILD.bazel
special_test(
    name = "my-special-test",
    srcs = ["test.cc"],
    copts = ["-std=c++22"],
)

This can be done by setting the attributes you don’t want to inherit to None.

# defs.bzl
special_test = macro(
    inherit_attrs = native.cc_test,
    attrs = { "copts": None },
    implementation = _special_test_impl,
)

Now the macro caller will see that copts is not possible to declare when calling the macro:

$ bazel query //test/package:my-special-test
        File "defs.bzl", line 19, column 1, in special_test
                special_test = macro(
Error: no such attribute 'copts' in 'special_test' macro

Keep in mind that all inherited attributes are going to be included in the kwargs parameter with the default value of None unless specified otherwise. This means you have to be extra careful in the macro implementation function if you refactor a legacy macro: you can no longer merely check for the presence of a key in the kwargs dictionary.

Mutation

In symbolic macros, you will not be able to mutate the arguments passed to the macro implementation function.

# defs.bzl
def _simple_macro_impl(name, visibility, env):
    print(type(env), env)
    env["some"] = "more"

simple_macro = macro(
    attrs = {
        "env": attr.string_dict(configurable = False)
    },
    implementation = _simple_macro_impl
)

# BUILD.bazel
simple_macro(name = "tool", env = {"state": "active"})

Let’s check how this would get evaluated:

$ bazel cquery //...
DEBUG: defs.bzl:36:10: dict {"state": "active"}
        File "defs.bzl", line 37, column 17, in _simple_macro_impl
                env["some"] = "more"
Error: trying to mutate a frozen dict value

This, however, is no different to legacy macros where you could not modify mutable objects in place either. In situations like this, creating a new dict with env = dict(env) would be of help.

In legacy macros you can still modify objects in place when they are inside the kwargs, but this arguably leads to code that is harder to reason about and invites subtle bugs that are a nightmare to troubleshoot in a large codebase. See the Mutability in Starlark section to learn more.

This is still possible in legacy macros:

# defs.bzl
def special_test_legacy(name, **kwargs):
    kwargs["name"] = name
    kwargs["env"]["some"] = "more"
    cc_test(**kwargs)

# BUILD.bazel
special_test_legacy("small-test", env = {"state": "active"})

Let’s see how the updated environment variables were set for the cc_test target created in the legacy macro:

$ bazel cquery //test/package:small-test --output=build
...
cc_test(
  name = "small-test",
  ...
  env = {"state": "active", "some": "more"},
)

This is no longer allowed in symbolic macros:

# defs.bzl
def _simple_macro_impl(name, visibility, **kwargs):
    print(type(kwargs["env"]), kwargs["env"])
    kwargs["env"]["some"] = "more"

It would fail to evaluate:

$ bazel cquery //...

DEBUG: defs.bzl:35:10: dict {"state": "active"}
        File "defs.bzl", line 36, column 27, in _simple_macro_impl
                kwargs["env"]["some"] = "more"
Error: trying to mutate a frozen dict value

Configuration

Symbolic macros, just like legacy macros, support configurable attributes, commonly known as select(), a Bazel feature that lets users determine the values of build rule (or macro) attributes at the command line.

Here’s an example symbolic macro with the select toggle:

# defs.bzl
def _special_test_impl(name, **kwargs):
    cc_test(
        name = name,
        **kwargs
    )
special_test = macro(
    inherit_attrs = native.cc_test,
    attrs = {},
    implementation = _special_test_impl,
)

# BUILD.bazel
config_setting(
    name = "linking-static",
    define_values = {"static-testing": "true"},
)

config_setting(
    name = "linking-dynamic",
    define_values = {"static-testing": "false"},
)

special_test(
    name = "my-special-test",
    srcs = ["test.cc"],
    linkstatic = select({
        ":linking-static": True,
        ":linking-dynamic": False,
        "//conditions:default": False,
    }),
)

Let’s see how this expands in the BUILD file:

$ bazel query //test/package:my-special-test --output=build
cc_test(
  name = "my-special-test",
  ...(omitted for brevity)...
  linkstatic = select({
    "//test/package:linking-static": True,
    "//test/package:linking-dynamic": False,
    "//conditions:default": False
  }),
)

The query command does show that the macro was expanded into a cc_test target, but it does not show what the select() is resolved to. For this, we would need to use the cquery (configurable query) which is a variant of query that runs after select()s have been evaluated.

$ bazel cquery //test/package:my-special-test --output=build
cc_test(
  name = "my-special-test",
  ...(omitted for brevity)...
  linkstatic = False,
)

Let’s configure the test to be statically linked:

$ bazel cquery //test/package:my-special-test --output=build --define="static-testing=true"
cc_test(
  name = "my-special-test",
  ...(omitted for brevity)...
  linkstatic = True,
)

Each attribute in the macro function explicitly declares whether it tolerates select() values, in other words, whether it is configurable. For common attributes, consult the Typical attributes defined by most build rules to see which attributes can be configured. Most attributes are configurable, meaning that their values may change when the target is built in different ways; however, there are a handful which are not. For example, you cannot assign a *_test target to be flaky using a select() (e.g., to mark a test as flaky only on aarch64 devices).

Unless specifically declared, all attributes in symbolic macros are configurable (if they support this) which means they will be wrapped in a select() (that simply maps //conditions:default to the single value), and you might need to adjust the code of the legacy macro you migrate. For instance, this legacy code used to append some dependencies with the .append() list method, but this might break:

# defs.bzl
def _simple_macro_impl(name, visibility, **kwargs):
    print(kwargs["deps"])
    kwargs["deps"].append("//:commons")
    cc_test(**kwargs)

simple_macro = macro(
    attrs = {
        "deps": attr.label_list(),
    },
    implementation = _simple_macro_impl,
)

# BUILD.bazel
simple_macro(name = "simple-test", deps = ["//:helpers"])

Let’s evaluate the macro:

$ bazel cquery //...
DEBUG: defs.bzl:35:10: select({"//conditions:default": [Label("//:helpers")]})
        File "defs.bzl", line 36, column 19, in _simple_macro_impl
                kwargs["deps"].append("//:commons")
Error: 'select' value has no field or method 'append'

Keep in mind that select is an opaque object with limited interactivity. It does, however, support modification in place, so that you can extend it, e.g., with kwargs["deps"] += ["//:commons"]:

$ bazel cquery //test/package:simple-test --output=build
...
cc_test(
  name = "simple-test",
  generator_name = "simple-test",
  ...
  deps = ["//:commons", "//:helpers", "@rules_cc//:link_extra_lib"],
)

Be extra vigilant when dealing with attributes of bool type that are configurable because the return type of select converts silently in truthy contexts to True. This can lead to some code being legitimate, but not doing what you intended. See Why does select() always return true? to learn more.

When refactoring, you might need to make an attribute configurable, however, it may stop working using the existing macro implementation. For example, imagine you need to pass different files as input to your macro depending on the configuration specified at runtime:

# defs.bzl
def _deployment_impl(name, visibility, filepath):
    print(filepath)
    # implementation

simple_macro = macro(
    attrs = {
        "filepath": attr.string(),
    },
    implementation = _deployment_impl,
)

# BUILD.bazel
deployment(
    name = "deploy",
    filepath = select({
        "//conditions:default": "deploy/config/dev.ini",
        "//:production": "deploy/config/production.ini",
    }),
)

In rules, select() objects are resolved to their actual values, but in macros, select() creates a special object of type select that isn’t evaluated until the analysis phase, which is why you won’t be able to get actual values out of it.

$ bazel cquery //:deploy
...
select({
    Label("//conditions:default"): "deploy/config/dev.ini",
    Label("//:production"): "deploy/config/production.ini"
    })
...

In some cases, such as when you need to have the selected value available in the macro function, you can have the select object resolved before it’s passed to the macro. This can be done with the help of an alias target, and the label of a target can be turned into a filepath using the special location variable:

# defs.bzl
def _deployment_impl(name, visibility, filepath):
    print(type(filepath), filepath)
    native.genrule(
        name = name + "_gen",
        srcs = [filepath],
        outs = ["config.out"],
        cmd = "echo '$(location {})' > $@".format(filepath)
    )

deployment = macro(
    attrs = {
        "filepath": attr.label(configurable = False),
    },
    implementation = _deployment_impl,
)

# BUILD.bazel
alias(
    name = "configpath",
    actual = select({
        "//conditions:default": "deploy/config/dev.ini",
        "//:production": "deploy/config/production.ini",
    }),
    visibility = ["//visibility:public"],
)

deployment(
    name = "deploy",
    filepath = ":configpath",
)

You can confirm the right file is chosen when passing different configuration flags before building the target:

$ bazel cquery //tests:configpath --output=build
INFO: Analyzed target //tests:configpath (0 packages loaded, 1 target configured).
...
alias(
  name = "configpath",
  visibility = ["//visibility:public"],
  actual = "//tests:deploy/config/dev.ini",
)
...

$ bazel build //tests:deploy_gen && cat bazel-bin/tests/config.out
...
DEBUG: defs.bzl:29:10: Label //tests:configpath
...
tests/deploy/config/dev.ini

Querying macros

Since macros are evaluated when BUILD files are queried, you cannot use Bazel itself to query “raw” BUILD files. Identifying definitions of legacy macros is quite difficult, as they resemble Starlark functions, but instantiate targets. Using bazel cquery with the --output=starlark might help printing the properties of targets to see if they have been instantiated from macros.

When using --output=build, you can also inspect some of the properties:

  • generator_name (the name attribute of the macro)
  • generator_function (which function generated the rules)
  • generator_location (where the macro was invoked)

This information with some heuristics might help you to identify the macros. Once you have identified the macro name, you can run bazel query --output=build 'attr(generator_function, simple_macro, //...)' to find all targets that are generated by a particular macro. Finding symbolic macros, in contrast, is trivial as you would simply need to grep for macro() function calls in .bzl files.

To query unprocessed BUILD files, you might want to use buildozer which is a tool that lets you query the contents of BUILD files using a static parser. The tool will come in handy for various use cases when refactoring, such as migrating the macros. Because both legacy and symbolic macros follow the same BUILD file syntax, buildozer can be used to query build metadata for either type.

Let’s write some queries for these macro invocations:

# BUILD.bazel
perftest(
  name = "apis",
  srcs = ["//:srcA", "//:srcB"],
  env = {"type": "performance"},
)

perftest(
  name = "backend",
  srcs = ["//:srcC", "//:srcD"],
  env = {"type": "performance"},
)

Print all macro invocations (raw) across the whole workspace:

$ buildozer 'print rule' "//...:%perftest"

perftest(
    name = "apis",
    srcs = [
        "//:srcA",
        "//:srcB",
    ],
    env = {"type": "performance"},
)

perftest(
    name = "backend",
    srcs = [
        "//:srcC",
        "//:srcD",
    ],
    env = {"type": "performance"},
)

Print attribute’s values for all macro invocations:

$ buildozer 'print label srcs' "//...:%perftest"
//test/package:apis [//:srcA //:srcB]
//test/package:backend [//:srcC //:srcD]

Print path to files where macros are invoked:

$ buildozer 'print path' "//...:%perftest" | xargs realpath --relative-to "$PWD" | sort | uniq
test/package/BUILD.bazel

The path can be combined with an attribute, e.g., print path and the srcs to make reviewing easier:

$ buildozer 'print path srcs' "//...:%perftest"
/home/user/code/project/test/package/BUILD.bazel [//:srcA //:srcB]
/home/user/code/project/test/package/BUILD.bazel [//:srcC //:srcD]

Remove an attribute from a macro invocation (e.g., env will be set up in the macro implementation function):

$ buildozer 'remove env' "//...:%perftest"
fixed /home/user/code/project/test/package/BUILD.bazel

You might also want to check that no macro invocation passes an attribute that is not supposed to be passed. In the command output, the missing means the attribute doesn’t exist; these lines can of course be ignored with grep -v missing:

$ buildozer -quiet 'print path env' "//...:%perftest" 2>/dev/null
/home/user/code/project/test/package/BUILD.bazel {"type": "performance"}
/home/user/code/project/test/package/BUILD.bazel (missing)

We hope that these practical suggestions and examples will assist you in your efforts to modernize the use of macros throughout your codebase. Remember that you can compose legacy and symbolic macros, which may be useful during the transition. Also, legacy macros can still be used and are to remain supported in Bazel for the foreseeable future. Some organizations may even choose not to migrate at all, particularly if they rely on the current behavior of the legacy macros heavily.

Behind the scenes

Alexey Tereshenkov

Alexey is a build systems software engineer who cares about code quality, engineering productivity, and developer experience.

If you enjoyed this article, you might be interested in joining the Tweag team.

This article is licensed under a Creative Commons Attribution 4.0 International license.

Company

AboutOpen SourceCareersContact Us

Connect with us

© 2025 Modus Create, LLC

Privacy PolicySitemap