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.