There are two ways to extend Bazel: macros and rules. They look similar from the user’s perspective — both let you write my_custom_thing(name = "foo", ...) in a BUILD file. But they’re fundamentally different in when they run and what they can do.

Macros: Loading-Phase Sugar

A macro is a plain Starlark function that calls existing rule functions. It runs during Loading and expands into target registrations — it’s syntactic sugar for declaring multiple targets from one call:

def pyo3_module(name, srcs, deps = [], visibility = None):
    # This function runs at Loading time.
    # It doesn't "do" anything — it just registers targets.
    rust_shared_library(
        name = name + "_rs",
        srcs = srcs,
        deps = deps + ["@crate_index//:pyo3"],
    )
    py_library(
        name = name,
        data = [":" + name + "_rs"],
        imports = ["."],
        visibility = visibility,
    )

The user writes pyo3_module(name = "my_module", ...) and gets two targets: my_module_rs and my_module. The macro is gone after Loading — it expanded into existing rule calls.

What macros CAN do: Call existing rule functions. Pass through attribute values. Simple string manipulation for generating names.

What macros CANNOT do: Access toolchains (don’t know the platform yet). Read provider metadata from dependencies (Analysis hasn’t happened). Declare actions (that’s a rule implementation concern). Anything that requires knowing how to build, not just what to build.

When to use: Standardizing how targets are declared. “Every PyO3 module in this repo should be a rust_shared_library + py_library wrapper with these defaults.” A macro is just a template.

Bazel 7+ symbolic macros add a formal schema to macros, making them better integrated with bazel query and able to enforce naming conventions:

pyo3_module = macro(
    implementation = _pyo3_module_impl,
    attrs = {"srcs": attr.label_list(allow_files = [".rs"])},
)

Rules: Analysis-Phase Build Logic

A rule creates a new type of build target with its own Analysis-phase logic. When Bazel encounters a target of your rule type, it calls your implementation function during Analysis, passing a ctx object. Your function reads from dependencies, declares build commands, and returns metadata.

Defining a Rule

pyo3_extension = rule(
    implementation = _pyo3_extension_impl,
    attrs = {
        "srcs": attr.label_list(allow_files = [".rs"]),
        "module_name": attr.string(mandatory = True),
        "deps": attr.label_list(providers = [CrateInfo]),  # type-checked
        # Private attributes (prefixed with _) aren't user-settable
        "_cc_toolchain": attr.label(default = "@bazel_tools//tools/cpp:current_cc_toolchain"),
    },
    toolchains = ["@rules_rust//rust:toolchain_type", "@rules_python//python:toolchain_type"],
)

The attrs dict defines the schema — what the user can pass. providers = [CrateInfo] means Bazel enforces that only targets returning CrateInfo can go in deps (see provider requirements). The toolchains list declares what toolchain types this rule needs — Bazel resolves them before calling your implementation (see Bazel Toolchains and Platforms).

Attribute Types

attr.label()          # A dependency on another target
attr.label_list()     # A list of dependencies
attr.string()         # A string
attr.string_list()    # A list of strings
attr.int()            # An integer
attr.bool()           # A boolean
attr.output()         # A declared output file

Key parameters on attr.label(): providers (required provider types), allow_files (accept file targets), cfg (build for exec or target platform — see Bazel Configurations and Transitions), executable (must be executable), default, mandatory.

The ctx Object

Your implementation function receives ctx — the gateway to everything:

def _pyo3_extension_impl(ctx):
    # --- Read identity ---
    ctx.label              # Label("//rust/my_module:my_module")
    ctx.label.name         # "my_module"
 
    # --- Read attributes ---
    ctx.attr.module_name   # string value
    ctx.attr.deps          # list of already-analyzed Target objects
    ctx.files.srcs         # list of File objects (convenience — unwraps from Targets)
 
    # --- Read toolchains ---
    rust_tc = ctx.toolchains["@rules_rust//rust:toolchain_type"]
    rustc = rust_tc.rustc  # File: the rustc binary
 
    # --- Declare actions (see below) ---
    # --- Return providers (see Providers note) ---

When to Use Macros vs Rules

ScenarioUse
Standardize a combination of existing rulesMacro
Need toolchains or configuration infoRule
Need to create a custom providerRule
Need custom build commandsRule
Quick convenience wrapperMacro

Start with a macro. Graduate to a rule when you hit a macro’s limitations.


Complete Rule Example

Here’s a full, realistic rule implementation — a PyO3 extension that compiles Rust into a Python-importable .so. It demonstrates the complete flow: read toolchains → read dependency providers → declare output → declare action → return providers.

# tools/pyo3_module/defs.bzl
load("@rules_rust//rust:defs.bzl", "rust_common")
load("@rules_python//python:defs.bzl", "PyInfo")
 
# ── Define the rule ──
 
def _pyo3_extension_impl(ctx):
    # ═══ READ FROM TOOLCHAINS ═══
    # Bazel resolved these before calling us (because we declared them below)
    rust_tc = ctx.toolchains["@rules_rust//rust:toolchain_type"]
    python_tc = ctx.toolchains["@rules_python//python:toolchain_type"]
    rustc = rust_tc.rustc           # File: the rustc binary
    rust_std = rust_tc.rust_std     # List[File]: standard library .rlib files
 
    # ═══ READ FROM DEPENDENCIES ═══
    # Each dep was already analyzed and returned providers.
    # We read CrateInfo to learn their compiled output paths.
    dep_rlibs = []
    extern_flags = []
    transitive = []
    for dep in ctx.attr.deps:
        ci = dep[rust_common.crate_info]
        dep_rlibs.append(ci.output)                   # the compiled .rlib
        extern_flags.append(ci.name + "=" + ci.output.path)
        transitive.append(ci.transitive_outputs)      # their transitive deps too
 
    # ═══ COMPUTE OUTPUT FILENAME ═══
    # Python expects a specific filename. Using stable ABI for simplicity.
    output = ctx.actions.declare_file(ctx.attr.module_name + ".abi3.so")
 
    # ═══ BUILD THE COMMAND LINE ═══
    args = ctx.actions.args()
    args.add("--edition=2021")
    args.add("--crate-type=cdylib")
    args.add("--crate-name", ctx.attr.module_name)
    args.add("-o", output)
    for flag in extern_flags:
        args.add("--extern", flag)
    args.add(ctx.files.srcs[0])  # the crate root (lib.rs)
 
    # ═══ DECLARE THE ACTION ═══
    # "Run rustc with these args. It reads these inputs. It produces this output."
    # Nothing executes now — this is a plan for the Execution phase.
    ctx.actions.run(
        executable = rustc,
        arguments = [args],
        inputs = depset(
            direct = ctx.files.srcs + dep_rlibs + [rustc] + rust_std,
            transitive = transitive,
        ),
        outputs = [output],
        mnemonic = "PyO3Rustc",
        progress_message = "Compiling PyO3 module %s" % ctx.label,
        env = {"PYO3_PYTHON": python_tc.py3_runtime.interpreter.path},
    )
 
    # ═══ RETURN PROVIDERS ═══
    # DefaultInfo: universal — what files we produce, what's needed at runtime
    # PyInfo: tells Python rules about us (we're a native extension, no .py files)
    return [
        DefaultInfo(
            files = depset([output]),
            runfiles = ctx.runfiles(files = [output]),
        ),
        PyInfo(
            transitive_sources = depset(),       # no .py files
            uses_shared_libraries = True,
            imports = depset([output.dirname]),   # add .so's dir to sys.path
        ),
    ]
 
pyo3_extension = rule(
    implementation = _pyo3_extension_impl,
    attrs = {
        "srcs": attr.label_list(allow_files = [".rs"], mandatory = True),
        "module_name": attr.string(mandatory = True),
        "deps": attr.label_list(providers = [rust_common.crate_info]),
    },
    toolchains = [
        "@rules_rust//rust:toolchain_type",
        "@rules_python//python:toolchain_type",
    ],
)

Usage in a BUILD file:

load("//tools/pyo3_module:defs.bzl", "pyo3_extension")
 
pyo3_extension(
    name = "my_module",
    srcs = glob(["src/**/*.rs"]),
    module_name = "my_module",
    deps = ["@crate_index//:pyo3"],
    visibility = ["//python:__subpackages__"],
)

Actions: Declaring Build Commands

An action is a build command declared during Analysis that Bazel runs during Execution. Bazel treats actions as pure functions: same inputs → same outputs, so they can be cached and run remotely.

When you declare an action, you’re telling Bazel: “there is a command to run. Here are the files it reads, the files it writes, and the command line.” Bazel uses this to build the action graph, check the cache, set up sandboxes, and determine what needs to rebuild when inputs change.

ctx.actions.run() — Run an external tool

ctx.actions.run(
    executable = rustc,                  # the tool to run (a File)
    arguments = [args],                  # command-line arguments
    inputs = depset([...]),              # ALL files the command reads
    outputs = [output_so],              # ALL files the command writes
    mnemonic = "PyO3Rustc",             # short label for progress display
    progress_message = "Compiling ...",
    env = {"PYO3_PYTHON": python.path}, # environment variables
)

Important

You must declare every input and every output. Forget an input → the sandbox won’t contain it and the action fails (or worse: works locally but fails in remote execution). Forget an output → Bazel doesn’t know the file exists and can’t use it downstream.

Declaring output files

Every output file must be declared before you reference it. declare_file() returns a File handle — an opaque reference to a file that doesn’t exist yet. You can’t read it during Analysis; you can only pass it as an output of one action and an input of another:

output = ctx.actions.declare_file("my_module.abi3.so")

Building command lines efficiently

For commands with many arguments (rustc can have hundreds of --extern flags), ctx.actions.args() provides a lazy builder — strings aren’t materialized until Execution, saving memory during Analysis:

args = ctx.actions.args()
args.add("--edition=2021")
args.add("--crate-type", "cdylib")
args.add_all(dep_infos, map_each = _format_extern)  # bulk add with a formatter
args.use_param_file("@%s")  # write args to a file if the command line gets too long

ctx.actions.run_shell() — When you need shell features

Use when you need pipes, redirects, or shell builtins. Prefer run() when possible — shell commands are harder to make hermetic.

log_file = ctx.actions.declare_file("build.log")
 
ctx.actions.run_shell(
    command = "{rustc} --crate-type=cdylib -o {out} {src} 2>&1 | tee {log}".format(
        rustc = rustc.path,
        out = output.path,
        src = crate_root.path,
        log = log_file.path,
    ),
    inputs = [rustc, crate_root] + dep_rlibs,
    outputs = [output, log_file],
    mnemonic = "PyO3RustcShell",
)

ctx.actions.write() — Generate a file at build time

Useful for config files, wrapper scripts, manifests:

wrapper = ctx.actions.declare_file(ctx.attr.module_name + "_wrapper.py")
 
ctx.actions.write(
    output = wrapper,
    content = """\
#!/usr/bin/env python3
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "{so_dir}"))
import {module}
{module}.main()
""".format(so_dir = output.dirname, module = ctx.attr.module_name),
    is_executable = True,
)

ctx.actions.args() — Advanced usage

The map_each parameter lets you transform a list of objects into command-line flags:

args = ctx.actions.args()
 
# Basic flags
args.add("--edition=2021")
args.add("--crate-type", "cdylib")    # becomes two args: "--crate-type" "cdylib"
args.add("-o", output)
 
# Bulk-add --extern flags from a list of CrateInfo objects
def _format_extern(crate_info):
    return crate_info.name + "=" + crate_info.output.path
 
args.add_all(dep_crate_infos, map_each = _format_extern, format_each = "--extern=%s")
 
# For very long command lines (rustc can have hundreds of --extern flags),
# write all args to a file and pass @argfile to the tool:
args.use_param_file("@%s", use_always = True)
args.set_param_file_format("multiline")

Aspects

An aspect is an advanced construct that propagates along the dependency graph, attaching additional Analysis logic to existing targets without modifying their BUILD files. A rule analyzes one target. An aspect analyzes a target and then automatically propagates to its dependencies.

Why they exist

Imagine you want to run clippy (Rust linter) on every Rust crate in the transitive dependency tree of your target. You can’t modify every BUILD file. An aspect lets you say: “for every target that has Rust source files, also run clippy on it, and propagate down through deps.”

Other use cases: generating IDE project files, auditing licenses across all transitive deps, building documentation for all dependencies.

How they work

An aspect definition looks like a rule, but the implementation receives target (the current target being visited) in addition to ctx:

def _clippy_aspect_impl(target, ctx):
    # Skip targets that aren't Rust (e.g., py_library in the dep graph)
    if rust_common.crate_info not in target:
        return []
 
    crate_info = target[rust_common.crate_info]
    rust_tc = ctx.toolchains["@rules_rust//rust:toolchain_type"]
 
    # Declare an output for this target's clippy results
    clippy_out = ctx.actions.declare_file(target.label.name + ".clippy.txt")
 
    # Declare an action to run clippy
    args = ctx.actions.args()
    args.add("--crate-type", crate_info.type)
    args.add("--crate-name", crate_info.name)
    args.add(crate_info.root)
 
    ctx.actions.run(
        executable = rust_tc.clippy_driver,
        arguments = [args],
        inputs = crate_info.srcs,
        outputs = [clippy_out],
        mnemonic = "Clippy",
    )
 
    # Collect clippy outputs from deps that this aspect already visited
    transitive_clippy = [
        dep[OutputGroupInfo].clippy
        for dep in ctx.rule.attr.deps
        if hasattr(dep[OutputGroupInfo], "clippy")
    ]
 
    # Return results — both this target's AND all transitive results
    return [
        OutputGroupInfo(clippy = depset(
            direct = [clippy_out],
            transitive = transitive_clippy,
        )),
    ]
 
clippy_aspect = aspect(
    implementation = _clippy_aspect_impl,
    attr_aspects = ["deps"],  # propagate down through "deps"
    toolchains = ["@rules_rust//rust:toolchain_type"],
)
 
# To use it, attach the aspect to an attribute in a rule:
clippy_check = rule(
    implementation = _clippy_check_impl,
    attrs = {
        "target": attr.label(aspects = [clippy_aspect]),
    },
)

When you build a clippy_check target, the aspect automatically propagates through the entire dependency tree of target, running clippy on every Rust crate it encounters.

Most engineers won’t write aspects. Knowing they exist helps when using features like rules_rust’s built-in clippy integration or IDE support tools.