4.1.2 Legacy Macros
Legacy macros are ordinary Starlark functions loaded from .bzl files and called from BUILD files. They run while Bazel is loading packages, create concrete rule targets, and then disappear from Bazel's model: after loading, Bazel mostly sees the generated targets, not the macro call that produced them.1 That transparency is their strength and their danger. They are cheap wrappers around existing rules, but they do not give you the typed API, private macro visibility, or future lazy expansion model of symbolic macros.2
The Shape Of A Legacy Macro
A legacy macro looks like a Python-style function because that is exactly how it is declared in Starlark. Put the function in a .bzl file, load it from a BUILD file, and call native or Starlark rules inside the function.3
# tools/images/miniature.bzl
def miniature(name, src, size = "100x100", visibility = None, **kwargs):
"""Creates a resized image target."""
native.genrule(
name = name,
srcs = [src],
outs = ["small_" + src],
cmd = "convert $< -resize %s $@" % size,
visibility = visibility,
**kwargs
)
# app/BUILD.bazel
load("//tools/images:miniature.bzl", "miniature")
miniature(
name = "logo_miniature",
src = "logo.png",
tags = ["manual"],
)
The usual convention is that a public macro takes a required name argument, documents its behavior with a docstring, and forwards common rule attributes such as visibility or tags when that is part of its API.4 **kwargs is useful when a macro is intentionally a thin wrapper, but it also expands the public surface: callers can pass anything the wrapped rule accepts, and the macro author now inherits that compatibility burden.
What Bazel Sees After Loading
Macros are evaluated during the loading phase, before rule implementation functions run during analysis.5 Calling miniature(name = "logo_miniature", ...) does not create a macro node in the analyzed graph. It creates the genrule target named logo_miniature, and later phases reason about that target as if it had been written directly in the BUILD file, with only limited generator metadata left behind.6
That is why bazel query --output=build is the first debugging tool for legacy macros. It prints the post-loading target declarations, so you can inspect the attributes the macro actually passed to the generated rules.7 For larger packages, the generator_function and generator_name attributes help filter targets created by a particular macro function or macro invocation.8 This is the practical follow-up to 4.3.2 Macro Expansion Inspection.
The same transparency makes failures harder to assign. If a macro emits five targets and the third one has a bad attribute, the user may need to read the macro body to understand why the target exists at all. The .bzl style guide treats this as a design cost: every layer of BUILD-file abstraction makes the file harder for humans and tools to inspect.9
Use native Deliberately
Inside a .bzl macro, built-in rules are available through the native module: native.cc_library(), native.genrule(), and the other native rule functions.10 native is loading-phase machinery. It is appropriate in macros, but not in rule implementation functions. Custom rules get a ctx object during analysis instead.11
Two native helpers matter often in macro code:
native.package_name()returns the package of theBUILDfile currently calling the macro, not the package where the.bzlfile lives.12native.package_relative_label()converts a string orLabelto aLabelusing the caller package's context, matching the way a label-valued rule attribute would interpret that string.13
Also remember that attributes passed as None to native rules are ignored and treated as unset.14 That is why visibility = None is a useful default: the macro can forward it without manufacturing its own replacement value and accidentally changing the wrapped rule's behavior.
Label Strings Are The Sharp Edge
Hardcoded label strings inside a legacy macro are interpreted from the package where the macro is used, not from the package where the macro is defined.15 That is convenient when the caller passes package-local inputs like ":src" or "logo.png", but dangerous when a reusable ruleset macro needs to refer to one of its own helper targets.
Use Label() for labels that belong to the macro's own .bzl context:
# @my_ruleset//tools:defs.bzl
_HELPER = Label("//tools:helper")
def wrapped_library(name, deps = [], **kwargs):
native.cc_library(
name = name,
deps = deps + select({
Label("//config:use_helper"): [_HELPER],
"//conditions:default": [],
}),
**kwargs
)
Label() resolves a string in the package where the calling .bzl file lives, and returns existing Label values unchanged.16 That is the right choice for fixed implementation labels such as compilers, helper tools, config settings, or repository-internal support targets. Use native.package_relative_label() instead when the macro receives a string from the BUILD author and should normalize it using the caller's package context.17
This distinction becomes more important with external repositories. A loading-phase macro can read Label(target_label).repo_name or .workspace_root, which works when the label itself identifies the repository. But there is a boundary: a macro cannot follow an alias target's actual attribute, because aliases are rules and rule analysis has not happened yet.18 If the behavior depends on the analyzed target, you have crossed back toward the rule side of 4.1.1 Macro vs Rule Decision Framework.
Keep Generated Targets Predictable
When a legacy macro creates helper targets, name them as a family. The style guide recommends that the macro define a main target with exactly the macro's name, and derive helper names by prefixing them with that same value.19 For example, bundle(name = "app") can reasonably create app, app_manifest, and app_packaged, but should not quietly create unrelated names.
Implementation-detail targets should have restricted visibility and often a manual tag so wildcard builds do not expand them unnecessarily.20 This is still ordinary target visibility, not symbolic macro-private visibility. Legacy macros are transparent to Bazel's visibility system, so their generated targets behave as if they were declared at the call site.21 If a helper target is not part of the macro's API, make that obvious through its name, visibility, and documentation.
Do not create a macro just to remove a little repetition. BUILD files are read and edited by people and tools. The style guide explicitly says DRY is not a good enough reason by itself.22 A legacy macro earns its place when it encodes a stable project convention, creates a coherent family of targets, or keeps a repetitive tool invocation correct across packages.
Know When To Move On
Legacy macros are fragile when their parameters make hidden assumptions. The official legacy macro tutorial uses an image-resizing macro where outs = ["small_" + src] only works if src is a filename string. It breaks for labels from another package or for select() values.23 That is the kind of assumption a macro author must either document clearly, push into a wrapped rule attribute, or replace with a stricter abstraction.
For Bazel 8+ code, symbolic macros are the preferred macro form when typed attributes, automatic label conversion, structured visibility, naming constraints, or future lazy evaluation matter.24 Migration details belong in M5 Legacy → Symbolic Macros, but the decision point is simple: use a legacy macro when you need a transparent function-shaped wrapper, especially for compatibility or existing code. Use 4.1.3 Symbolic Macros (Bazel 8+) when Bazel should understand the macro API. Use 4.2 Custom Rules, Providers & Actions when the abstraction needs providers, actions, outputs, runfiles, or analyzed dependency data.
The mini-ruleset's glyph_legacy_app is a compact, runnable legacy macro whose generated helper and public target make that transparency concrete.
Legacy macros are loading-phase functions that stamp out ordinary targets. Treat them as a BUILD-file API over existing rules, not as a substitute for rule analysis.
The safe pattern is small and explicit: required name, predictable generated names, deliberate native calls, careful label-context handling with Label() or native.package_relative_label(), and a clear escalation path when the wrapper needs typed macro semantics or a real rule contract.
Check your understanding · 4 questions
1.What remains after a legacy macro finishes evaluating during the loading phase?
Select one answer
2.Match each legacy-macro tool to its role.
Drag each answer onto the matching prompt, or click an answer and then click a prompt
native.cc_library()Label()native.package_relative_label()bazel query --output=build3.True or false: safe legacy macro design.
Choose True or False for each sentence
name argument.native is the right API for declaring actions inside a custom rule implementation.4.A legacy macro forwards an optional attr to native.cc_library(), and the caller omitted that attr. Why should the macro usually forward None instead of substituting its own default value?
Select one answer
Footnotes
-
Legacy Macros — definition and disappearance after the loading phase. ↩
-
Macros — symbolic macro typed arguments, visibility, and future lazy evaluation model. ↩
-
Creating a Legacy Macro —
.bzlfunction loaded from aBUILDfile and wrappingnative.genrule(). ↩ -
Legacy Macros — public macro conventions: required
name, docstring, keyword call style, and optionalvisibility. ↩ -
Extension Overview — loading phase versus analysis phase. ↩
-
Extension Overview — macros instantiate rules and Bazel later behaves almost as if generated rules were written directly. ↩
-
Legacy Macros — inspecting macro expansion with
bazel query --output=build. ↩ -
Legacy Macros — filtering expanded output by
generator_functionandgenerator_name. ↩ -
.bzl style guide — abstraction costs for humans, tools, query output, and aspects. ↩
-
native — built-in native rules exposed as functions such as
native.cc_library. ↩ -
native —
nativeis available in the loading phase for macros, not rule implementations. ↩ -
native —
package_name()returns the package currently being evaluated. ↩ -
native —
package_relative_label()semantics for macro callers. ↩ -
Legacy Macros — label strings in legacy macros are interpreted relative to the caller BUILD file. ↩
-
Label —
Label()conversion in the calling.bzlfile's package context. ↩ -
native — usage note contrasting
Label()withnative.package_relative_label(). ↩ -
Migrating to Bazel Modules (a.k.a. Bzlmod) - Repo Names, Macros, and Variables — loading-phase macros can read label repository fields but cannot analyze alias targets. ↩
-
.bzl style guide — main target and generated target naming conventions. ↩
-
.bzl style guide — restricted visibility and
manualtag guidance for generated helper targets. ↩ -
Macros — legacy macros are transparent to the visibility system. ↩
-
.bzl style guide — BUILD-file simplicity and why DRY alone is not enough. ↩
-
Creating a Legacy Macro — fragility of filename-string assumptions and recommendation to document or prefer symbolic macros. ↩
-
Macros — symbolic macro benefits and recommendation where possible. ↩