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' macroKeep 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 valueThis, 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 valueConfiguration
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.iniQuerying 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.bazelThe 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.bazelYou 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 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.