Bazel - Workspace, Commands and Targets

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 rootBUILD
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 thecc_example
package.bazel run //cc_example:example
: Runs the binary target namedexample
.bazel test //cc_example/...
: Executes all tests under thecc_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 ofcc_
.
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.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.
Subscribe to my newsletter
Read articles from Lucas Munaretto directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
