Introduction to aspects in Bazel

Bazel’s way of attaching additional information and behavior to the build graph is called aspects. They allow us to add extra logic to rules without modifying the rule implementations themselves. Common use cases include validation actions or analysis tasks such as detecting unused dependencies. These are just a few examples; aspects enable much more complex workflows.

A note on using aspects

Earlier, I mentioned that aspects allow us to attach additional logic to rules without modifying rule code. This is true, but only in one of the two ways aspects can be used:

  • Command-line invocation – aspects are applied externally at build time. This is what we will focus on in this article.
  • Attribute attachment – aspects are attached directly to rule attributes, which requires modifying the rule definition. This approach will be covered in the next article.

Writing aspects

Like most things in Bazel, aspects are rule-like and generally follow this pattern:

  1. Write an implementation function that accepts two arguments: target and ctx
  2. Optionally execute actions
  3. Return providers
  4. Create the aspect by calling the aspect() function and passing the implementation and configuration arguments

In this example, we will write an aspect that operates on swift_library targets (specifically, anything that propagates the SwiftInfo provider). The aspect will generate a .txt file containing the names of the Swift modules that the target depends on.

Given the following target:

swift_library(
    name = "lib",
    deps = [":lib2"],
    srcs = glob(["*.swift"]),
)

When the aspect is applied to this target, it will produce a text file containing lib2.

Loading required providers

In aspects.bzl, we first load the SwiftInfo provider from rules_swift:

load("@rules_swift//swift:providers.bzl", "SwiftInfo")

Next, we define our own provider to propagate the generated report files transitively:

SwiftDepsInfo = provider(fields = ["report_files"])

Aspect implementation

Now we can write the implementation function:

def _swift_deps(target, ctx):
    module_names = []

    if hasattr(ctx.rule.attr, "deps"):
        for dep in ctx.rule.attr.deps:
            if SwiftInfo in dep:
                for module in dep[SwiftInfo].direct_modules:
                    module_names.append(module.name)

    out = ctx.actions.declare_file("swift_deps_" + ctx.label.name + ".txt")
    ctx.actions.write(
        output = out,
        content = "\n".join(module_names),
    )

    transitive_files = []
    if hasattr(ctx.rule.attr, "deps"):
        for dep in ctx.rule.attr.deps:
            if SwiftDepsInfo in dep:
                transitive_files.append(dep[SwiftDepsInfo].report_files)

    all_files = depset(direct = [out], transitive = transitive_files)
    return [
        SwiftDepsInfo(report_files = all_files),
        DefaultInfo(files = all_files),
    ]

So the code above does few simple things:

  1. Collects Swift module names from the deps attribute via the SwiftInfo provider
  2. Declares an output file and writes the module names into it
  3. Collects transitive report files so they materialize as build artifacts
  4. Returns both a custom provider (SwiftDepsInfo) and DefaultInfo

Creating the aspect

The final step is to define the aspect itself:

swift_deps_aspect = aspect(
    implementation = _swift_deps,
    attr_aspects = ["deps"],
)

Note: The attr_aspects = ["deps"] argument tells Bazel to propagate this aspect along the deps attribute. In other words, when the aspect is applied to a rule, Bazel will also apply it to every target listed in that rule’s deps.

Running the aspect

Given the following BUILD.bazel file:

load("@rules_swift//swift:swift_library.bzl", "swift_library")

swift_library(
    name = "lib",
    srcs = ["main.swift"],
    deps = [":lib2"],
)

swift_library(
    name = "lib2",
    srcs = ["main.swift"],
    deps = [":lib3"],
)

swift_library(
    name = "lib3",
    srcs = ["main.swift"],
    module_name = "CustomModule",
)

Running:

bazel build :lib --aspects aspects.bzl%swift_deps_aspect

will produce three additional text files which you can locate under bazel-bin/:

  • swift_deps_lib.txt – contains lib2
  • swift_deps_lib2.txt – contains CustomModule
  • swift_deps_lib3.txt – empty, since it has no dependencies

Why does swift_deps_lib2.txt contain CustomModule instead of lib3? Because we are explicitly extracting the Swift module name from the SwiftInfo provider. If instead we wanted the target name, we could use dep.label.name, or str(dep.label) to get the fully qualified Bazel label.

Going further

This article was a brief introduction to Bazel aspects, intended to set the foundation for the next one. In the next article, we will look at more concrete use cases and explore attaching aspects directly to rule attributes from Starlark as well as utilizing some of the other arguments on that aspect() function.

Bazel toolchains, repository rules and module extensions

In my previous article I showed how to create a stupidly simple Bazel rule. While that rule was not useful in any way, shape, or form, it provided a gentle introduction to writing Bazel rules and helped build a mental model around them.

This week we will look at toolchains, which is a more advanced concept but becomes extremely important once your rules depend on external tools.

This walkthrough wires up macOS only to keep the example small. Adding Linux or Windows is the same pattern: download a different binary and register another toolchain(...) target with different compatibility constraints.

Toolchains

A Bazel toolchain is a way of abstracting tools away from rules. Many people describe it as dependency injection.

More precisely: toolchains are dependency injection with platform-based resolution — Bazel selects the right implementation based on the execution and target platforms.

Instead of hard-coding a tool inside a rule, the rule asks Bazel to give it “the correct tool for this platform”.

Example: rules_pkl

We will create oversimplified rule called pkl_library that turns .pkl files into their JSON representation using the PKL compiler. pkl is Apple’s programing language for producing configurations. There is official rules_pkl ruleset and this article doesn’t even scratch the surface of the capabilities that it offers.

To do this we need:

  1. A way to download the PKL binary
  2. A toolchain
  3. A rule that uses the toolchain

Downloading the PKL binary

We start by writing a repository rule that downloads the PKL compiler and exposes it as a Bazel target.

Create repositories.bzl:

def _pkl_download_impl(repository_ctx):
    repository_ctx.download(
        url = repository_ctx.attr.url,
        output = "pkl_bin",
        executable = True,
        sha256 = repository_ctx.attr.sha256,
    )

    repository_ctx.file(
        "BUILD.bazel",
        """
load("@bazel_skylib//rules:native_binary.bzl", "native_binary")

native_binary(
    name = "pkl",
    src = "pkl_bin",
    out = "pkl",
    visibility = ["//visibility:public"],
)
""",
    )

pkl_download = repository_rule(
    implementation = _pkl_download_impl,
    attrs = {
        "url": attr.string(mandatory = True),
        "sha256": attr.string(mandatory = True),
    },
)
````

This does two things:

* Downloads the PKL binary
* Wraps it using `native_binary`, which creates a proper Bazel executable without relying on a shell

The resulting binary is available as `@pkl_macos//:pkl`.

## Exposing the repository via a module extension

We’re still using a [repository rule](https://bazel.build/external/repo) to do the download; [bzlmod module extensions](https://bazel.build/external/extension) are just how we call that repository rule from `MODULE.bazel`.

Create `extensions.bzl`:

```starlark
load("//:repositories.bzl", "pkl_download")

def _pkl_module_extension_impl(ctx):
    pkl_download(
        name = "pkl_macos",
        url = "https://github.com/apple/pkl/releases/download/0.30.2/pkl-macos-aarch64",
        sha256 = "75ca92e3eee7746e22b0f8a55bf1ee5c3ea0a78eec14586cd5618a9195707d5c",
    )

pkl_extension = module_extension(
    implementation = _pkl_module_extension_impl,
)

In MODULE.bazel:

bazel_dep(name = "platforms", version = "1.0.0")
bazel_dep(name = "bazel_skylib", version = "1.9.0")

pkl_extension = use_extension("//:extensions.bzl", "pkl_extension")
use_repo(pkl_extension, "pkl_macos")

Now the binary can be referenced as @pkl_macos//:pkl.

Creating the toolchain

A toolchain is just a rule that returns a ToolchainInfo provider.

toolchains.bzl:

def _pkl_toolchain_impl(ctx):
    return [platform_common.ToolchainInfo(
        pkl_binary = ctx.executable.pkl_binary,
    )]

pkl_toolchain = rule(
    implementation = _pkl_toolchain_impl,
    attrs = {
        "pkl_binary": attr.label(
            executable = True,
            cfg = "exec",
            allow_files = True,
            mandatory = True,
        ),
    },
)

The important part is cfg = "exec", which ensures the binary runs on the execution platform.

registering the toolchain

In BUILD.bazel:

load("//:toolchains.bzl", "pkl_toolchain")

toolchain_type(
    name = "pkl_toolchain_type",
    visibility = ["//visibility:public"],
)

pkl_toolchain(
    name = "pkl_toolchain_macos_impl",
    pkl_binary = "@pkl_macos//:pkl",
)

toolchain(
    name = "pkl_toolchain_macos",
    toolchain = ":pkl_toolchain_macos_impl",
    toolchain_type = ":pkl_toolchain_type",
    exec_compatible_with = ["@platforms//os:macos"],
    target_compatible_with = ["@platforms//os:macos"],
)

To register the toolchain we need to modify our MODULE.bazel:

register_toolchains("//:pkl_toolchain")

Using the toolchain in a rule

Now we can write pkl_library.

Create rules.bzl:

def _pkl_library_impl(ctx):
    toolchain = ctx.toolchains["//:pkl_toolchain_type"]
    binary = toolchain.pkl_binary

    compiled_files = []
    for src in ctx.files.srcs:
        compiled_file_name = src.basename.replace(".pkl", ".json")
        compiled_file = ctx.actions.declare_file(compiled_file_name)

        ctx.actions.run(
            executable = binary,
            tools = [binary],
            inputs = [src],
            outputs = [compiled_file],
            arguments = [
                "eval",
                src.path,
                "--format",
                "json",
                "-o",
                compiled_file.path,
            ],
            mnemonic = "PKLCompile",
        )

        compiled_files.append(compiled_file)

    return [DefaultInfo(files = depset(compiled_files))]

pkl_library = rule(
    implementation = _pkl_library_impl,
    attrs = {
        "srcs": attr.label_list(
            allow_files = [".pkl"],
            mandatory = True,
        ),
    },
    toolchains = ["//:pkl_toolchain_type"],
)

The rule never knows which concrete PKL binary is being used — it only sees the resolved toolchain.

NOTE: Here instead of writing a for loop that creates new action per file is a decision that we need to make on a case by case basis. Sometimes it can be faster to run multiple actions in parallel than invoking a tool once and giving it a list of files to process. It heavily depends on the tool itself and shows us how bazel can be powerful in these scenarios.

Final thoughts

This was a practical look at repository rules, module extensions, toolchains, and how they fit together.

Toolchains are one of Bazel’s most powerful features. Once you start writing rules that depend on real tools (compilers, linters, generators), this pattern becomes unavoidable.

This marks the completion of my second article where I try to give real world examples of using bazel’s various features.

Writing a simple bazel rule

This article does not get into what the Bazel build system is or why you might consider using it. Instead, it focuses on explaining, in very simple terms, how to write a Bazel rule.

First things first

You need a Bazel repository, often referred to as a workspace. To create one, you need a MODULE.bazel file at the root of your project. This file is used to declare external dependencies, although that is not its only purpose. For now, let’s keep MODULE.bazel empty.

Next is a BUILD.bazel file. This is where rules are instantiated (used). The result of instantiating a rule is a Bazel target. Create an empty BUILD.bazel file at the root of the project as well.

Writing the rule

Think of a Bazel rule as a way to teach Bazel how to produce something. We will start by producing a simple text file and then make it slightly more complex.

We will call this rule hello. It will produce a file named hello.txt containing the word "hello".

Create a file called hello.bzl with the following content:

def _hello_impl(ctx):
    file = ctx.actions.declare_file(ctx.label.name + ".txt")
    ctx.actions.write(
        output = file,
        content = "hello",
    )
    return DefaultInfo(files = depset([file]))

This function is the implementation of our hello rule. Notice that the function name ends with _impl. This is a common Bazel convention for rule implementation functions, although it is not strictly required.

The function takes a single parameter, ctx. Every rule implementation receives a ctx (context) object, which provides access to attributes, labels, and the actions API used to interact with Bazel.

Before creating an action, we declare the output file:

file = ctx.actions.declare_file(ctx.label.name + ".txt")

This tells Bazel that the rule will produce a file named after the target (hello.txt in our case). The returned file object represents a declared output that can be passed to actions.

Next, we create an action that writes content to the file:

ctx.actions.write(
    output = file,
    content = "hello",
)

Here we explicitly tell Bazel what the output of the action is. Being explicit about outputs (and inputs, when present) is a defining characteristic of Bazel’s build model. In this example, there are no inputs—only an output.

Finally, we return a result from the rule using the DefaultInfo provider:

return DefaultInfo(files = depset([file]))

This makes the produced file part of the target’s default outputs. We will not go into providers or depset in this article; the official documentation covers those topics in depth.

Now that the implementation function exists, we define the rule itself by calling rule():

hello = rule(
    implementation = _hello_impl,
)

This is enough to define a usable rule.

Using the rule

In the previously created BUILD.bazel file, we first load the rule:

load(":hello.bzl", "hello")

Then we instantiate it:

hello(
    name = "hello",
)

Now run:

bazel build :hello

You should see output similar to:

INFO: Analyzed target //:hello (5 packages loaded, 7 targets configured).
INFO: Found 1 target...
Target //:hello up-to-date:
  bazel-bin/hello.txt
INFO: Elapsed time: 0.285s, Critical Path: 0.00s
INFO: 2 processes: 2 internal.
INFO: Build completed successfully, 2 total actions

Pay attention to the line bazel-bin/hello.txt. This is where Bazel exposes the output file (typically via a symlink). Open it with:

open bazel-bin/hello.txt

You should see that the file contains the word hello.

Rule attributes

To make this rule somewhat useful, we will add a new attribute called content that replaces the hardcoded "hello" string.

The first step is to declare that our rule has an attribute named content. We do this by providing a dictionary to the attrs parameter of rule():

hello = rule(
    implementation = _hello_impl,
    attrs = {
        "content": attr.string(mandatory = True),
    },
)

Here we declare a mandatory string attribute named content. Bazel will enforce that this attribute is provided when the rule is instantiated.

Next, we read the value of the attribute in the rule implementation function. Rule attributes are accessible through ctx.attr. We replace the hardcoded value with ctx.attr.content:

def _hello_impl(ctx):
    file = ctx.actions.declare_file(ctx.label.name + ".txt")
    ctx.actions.write(
        output = file,
        content = ctx.attr.content,
    )
    return DefaultInfo(files = depset([file]))

Finally, we provide the attribute value when instantiating the rule in the BUILD.bazel file:

hello(
    name = "hello",
    content = "Hello, world!",
)

After running:

bazel build :hello

the file located at bazel-bin/hello.txt will contain the provided text.

That’s it

This concludes my first article on the Bazel build system. I plan to expand this rule in subsequent articles to demonstrate more advanced concepts and gradually make it more useful. This also marks my first article of the year, and I plan to write one technical article every week until the year 2026 concludes.