Bazel - Workspace, Commands and Targets

Lucas MunarettoLucas Munaretto
7 min read

If you're here, it's probably because you’ve decided to give Bazel a shot. As good developers, we like to try things out without diving into massive amounts of documentation, so let’s go through some examples together. The idea is to get more familiar with a Bazel workspace, its commands, and its targets.

Before we continue, I’ve created a repository to help guide us along the way. You’ll find all the source code in the Unwrap Your Build repository.

GitHub: Source code plus helpful hints can be found under bazel_workspace_commands_targets.

Note: I won’t be covering WORKSPACE files, as they’re nearly deprecated at this point. Bazel 9 will completely remove support for this old dependency management system.

Heads up: We don’t have a hermetic setup yet, so Bazel will use your host toolchains. If you don’t have a C++ toolchain installed, stick with the Python example for now.

Bazel Workspace

I cannot describe it better than the official documentation, so here it goes:

A workspace is the environment shared by all Bazel commands run from the same main repo.

Note that historically the concepts of "repository" and "workspace" have been conflated; the term "workspace" has often been used to refer to the main repository, and sometimes even used as a synonym of "repository".

So, what is a Bazel repository? It's a source tree with a boundary marker file at its root, nowadays, a MODULE.bazel file. The main repository is usually your current Git repository, which can depend on other external repositories, each with its own MODULE.bazel file.

From this point forward, we'll consider that a defined set of repositories, the main one plus any external ones, comprises the workspace.

Let's create a minimal workspace and test it out. In a new directory, do the following:

touch MODULE.bazel
touch BUILD
bazel build //...

As you can see, with two empty files you already have a working Bazel workspace.

Workspace files

I won’t go into the details of every possible file, as that would be both boring and unproductive. However, the following list summarizes the most useful ones:

  • Loaded from another repository:

    • MODULE.bazel: Defines a Bazel repository as a Bazel module, and set its name, version, list of dependencies, and other information.

    • BUILD: Defines a Bazel package using Starlark code. It may contains multiple Bazel targets.

    • *.bzl files: Extend Bazel using Starlark code. Check the documentation for how to write and use these files.

  • Not loaded from external repositories:

    • .bazelrc: Bazel configuration file with its own syntax. It helps to maintain and control multiple build configurations.

    • .bazelversion: A single-line line file that specifies the Bazel version to be used for the current workspace, e.g. , 8.3.1.

    • .bazelignore: similar to .gitignore, tells Bazel which directories to ignore.

Note that I’ve split the files into two categories. Configuration files are not inherited as they belong only to their own workspace. In contrast, the files that define a repository are transitively loaded and therefore inherited by dependent workspaces.

After this brief introduction to workspace files, let’s complete our setup by adding some configurations.

cat > MODULE.bazel << EOL
module(
    name = "bazel_workspace_commands_targets",
    version = "0.0.1",
)
EOL
echo "common --lockfile_mode=off" >> .bazelrc
echo "8.3.1" >> .bazelversion
touch .bazelignore

Targets

To avoid introducing too many new concepts at once, think of a Bazel target as something you want to build or run. To create a target, you need a Bazel rule, which acts like a recipe for building that target. For example, if you want to create a C++ binary target, you can use the cc_binary rule.

All instantiated targets can be referenced using Bazel commands. However, Bazel doesn’t use file paths, instead, it uses labels. For instance:

  • If you define a target named our_target in the root BUILD file, its label is //:our_target.

  • If you define the same target in our_package/BUILD, its label becomes //our_package:our_target.

There are also special target patterns like //..., which are quite handy for running or building everything recursively.

Anyway, let’s not worry about all the details just yet. Let's jump into some example code. I’ve prepared two sets of examples, one for C++ and one for Python. Each includes a binary, a library, and a test.

C++ Example Target

Let's start creating our C++ code:

mkdir cc_example
cd cc_example

cat > example.cpp << EOL
#include "lib.h"
int main() {
    printMessage("Unwrap Your Build");
    return 0;
}
EOL

cat > lib.h << EOL
#include <iostream>
#include <string>
void printMessage(const std::string& message) {
    std::cout << message << std::endl;
}
EOL

cat > test.cpp << EOL
#include "lib.h"
int main() {
    printMessage("Unwrap Your Build");
    return 0;
}
EOL

With the source code in place, we now just need to tell Bazel to create the corresponding targets. To do this, we mark cc_example as a package by adding a BUILD file, and then use rules, such as cc_binary, cc_library, and cc_test, to define the targets under this package.

cat > BUILD << EOL
cc_binary(
    name = "example",
    srcs = ["example.cpp"],
    deps = [":lib"],
)

cc_library(
    name = "lib",
    hdrs = ["lib.h"],
)

cc_test(
    name = "test",
    srcs = ["test.cpp"],
    deps = [":lib"],
)
EOL

At this point, we have proper Bazel targets for our C++ code, which means we can use Bazel commands like build, run, and test on them. The following list summarizes these commands:

  • bazel build //cc_example/...: Builds all targets under the cc_example package.

  • bazel run //cc_example:example: Runs the binary target named example.

  • bazel test //cc_example/...: Executes all tests under the cc_example package.

Note that it's not necessary to build before running, Bazel will automatically build any required targets. Also, if you run the same commands again, Bazel will try to match cached results, so repeated actions will be much faster.

Python Example Target

Our Python example is basically the same as the C++ one. The differences are:

  • The source code, of course, as we’re writing Python now.

  • The rules used to instantiate targets start with py_ instead of cc_.

That’s it! Everything else remains the same. You can run the same Bazel commands. Just don't forget to change the target labels.

This is great because it provides an abstraction layer over very different programming languages, allowing you to build and run them in a consistent way.

I won’t include code blocks for this section, but you should definitely try it out yourself. Check out the python example in the GitHub repo.

Commands

We’ve already covered some basic Bazel commands in the previous sections. While Bazel has only a few core commands, it offers thousands of options that modify their behavior. Be sure to check the official documentation whenever you need to customize command usage.

For now, let’s focus on five main commands:

  • bazel build: Builds one or multiple targets (who would've thought?)

    • You don’t need to build explicitly before running or testing, Bazel knows what needs to be built automatically.

    • Bazel also caches built artifacts by default.

    • Every target can be built.

  • bazel run: Runs a specific executable target, usually created with *_binary rules.

  • bazel test: Runs one or multiple test targets, usually created with *_test rules.

    • Bazel caches successful test results by default.

    • Bazel also offers a coverage command for test coverage reports.

  • bazel query: Queries one or more targets.

    • Queries can return labels, configurations, locations, and other useful information—great for debugging or extracting details about your targets.

    • There are three types of queries: query, cquery, and aquery.

    • Check the Bazel query guide and reference for more details.

  • bazel clean: Deletes all output directories for this Bazel instance.

    • Add the --expunge flag to remove the entire output base tree.

    • This command may be common for build system developers but should not be part of other developers daily workflow. That's true, of course, if they are provided with a hermetic setup.

Please check the command playground that I prepared to become more familiar with them.

As you can see, with a few commands, you’re already equipped to do most things with Bazel. There are more commands out there of course, but those can be more advanced or only fit very specific use cases, so no need to worry about them for now.

Final Thoughts

Because Bazel supports multiple programming-languages and provides a human-readable abstraction layer on top of those, it's really straightforward to create, build, run, and test multiple targets.

Bazel also comes with powerful features out of the box. One great example is its local caching mechanism. While there are ways to improve it and even transform it into a remote cache, the default mechanism is already great.

We won't dive into details of Bazel's caching just yet, but one key way to improve cache hits is by making our builds hermetic. This not only boosts cache effectiveness but also makes our builds more consistent across different machines, say goodbye to "it works on my machine".

Stay tuned for the next articles, where we’ll likely use Bazel’s built-in dependency manager to make our builds more hermetic and to reduce reliance on system dependencies.

0
Subscribe to my newsletter

Read articles from Lucas Munaretto directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Lucas Munaretto
Lucas Munaretto