Do it with bazel

Hello community! Over the past few years bazel has become my preferred build tool and I figured I’d do a post on how to get started with Bazel. This is more of a “Hello World” article than a discussion of the trade offs. Covering the tradeoffs of between bazel and another tool like cmake would be a much more detailed article. Instead my hope with this article is just to inspire curiosity by giving a light intro.


So what is bazel?

bazel is a feature rich tool. The most pratical feature for me is being able to compile and build C++ code into binaries and libaries. (bazel can do so much more than just that though, one of my favorite other features is using python libraries and dependency management via bazel)

How do we get it?

For fresh users who’ve never used bazel before then you’re probably best served by using bazelisk

I personally install bazel using the steps here since I’m usually using some flavor of Debian and am a huge fan of the APT system but I would not recommend this unless you are very comfortable with Debian and APT.

How do we use it with C++ ?

For the rest of this article I’m going to use the code I published in my Benchmarking and Testing post from a few years ago. Only this time we’ll use bazel instead of cmake.

Here’s the files we’ll use as our faux library, let’s get them in a directory we can use them

source_code.h:

#ifndef SOURCECODE_H
#define SOURCECODE_H

#include <vector>

extern std::vector<int> do_something(void);

#endif

source_code.cpp:

#include "source_code.h"

#include <vector>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

std::vector<int> do_something(void)
{
  int fd = open("/dev/urandom", O_RDONLY);
  if(fd < 0)
    throw "failed to open file";
  int v = 0;
  read(fd, &v, 1);
  close(fd);
  std::vector<int> ret{v};
  return ret;
}

With these source files how do we actually build them?

In modern versions of bazel we’ll first need to add a MODULE.bazel file. (There is an older WORKSPACE system that is deprecated and is going to be removed in bazel 9) This file can be empty for now we’ll come back to it later

Next we’ll create a BUILD.bazel file for us to setup our library. That looks like this:

cc_library(
  name = "source_code",
  hdrs = ["source_code.h"],
  srcs = ["source_code.cpp"]
)

And from here -with just these 4 files- we can do:

bazel build //...

and see our library get built!

$ bazel-7.3.1 build //...
INFO: Analyzed target //:source_code (86 packages loaded, 394 targets configured).
INFO: Found 1 target...
Target //:source_code up-to-date:
  bazel-bin/libsource_code.a
  bazel-bin/libsource_code.so
INFO: Elapsed time: 2.588s, Critical Path: 0.23s
INFO: 6 processes: 3 internal, 3 linux-sandbox.
INFO: Build completed successfully, 6 total actions

And that’s it for a basic setup!

To implement the rest of the stuff from the bench and test we’ll just bring those files over and then modify our BUILD.bazel to include them. To refresh here’s what those files looked like:

source_code.test.cpp:

#include "source_code.h"
#include <gtest/gtest.h>

TEST(do_something, CorrectSize)
{
  const std::vector<int> ret = do_something();
  EXPECT_EQ(ret.size(), 1);
}

source_code.benchmark.cpp:

#include "source_code.h"
#include <benchmark/benchmark.h>

static void BM_do_something(benchmark::State & state)
{
  for(auto _ : state)
  {
    std::vector<int> foo;
    benchmark::DoNotOptimize(foo = do_something());
    benchmark::ClobberMemory();
  }
}

BENCHMARK(BM_do_something);

(Note this code is not a real benchmark that does anything meaningful. Check documentation before using benchmark::DoNotOptimize and benchmark::ClobberMemory. At time of writing, the documentation for these is here)

These files both have an extra dependency we need to bring in. The test file needs google test and the benchmark needs google benchmark. How do we bring those in? This is where we need that MODULE.bazel file that we left empty. So what do we put in there?

Bazel’s module system is exceptionally flexible so a lot of things are possible. The simplest method for dependencies with bazel’s module system is to look the dependency up on the Bazel Central Registry. In this simple case both google test and google benchmark are on the central registry so we can use the sample hooks that they mention.

This makes our MODULE.bazel look like this now:

bazel_dep(name = "googletest", version = "1.15.2")
bazel_dep(name = "google_benchmark", version = "1.9.1")

Now let’s go back to our BUILD.bazel and tie everything back together:

cc_library(
  name = "source_code",
  hdrs = ["source_code.h"],
  srcs = ["source_code.cpp"]
)

cc_test(
  name = "source_code_test",
  srcs = ["source_code.test.cpp"],
  deps = [
    ":source_code",
    "@googletest//:gtest",
    "@googletest//:gtest_main"
  ]
)

cc_binary(
  name = "source_code_benchmark",
  srcs = ["source_code.benchmark.cpp"],
  deps = [
    ":source_code",
    "@google_benchmark//:benchmark",
    "@google_benchmark//:benchmark_main"
  ]
)

Now we can do things like:

bazel-7.3.1 test //...

Giving us:

$ bazel-7.3.1 test //...
Starting local Bazel server and connecting to it...
INFO: Analyzed 3 targets (100 packages loaded, 648 targets configured).
INFO: Found 2 targets and 1 test target...
INFO: Elapsed time: 6.001s, Critical Path: 2.77s
INFO: 61 processes: 16 internal, 45 linux-sandbox.
INFO: Build completed successfully, 61 total actions
//:source_code_test                                                      PASSED in 0.0s

Executed 1 out of 1 test: 1 test passes.
There were tests whose specified size is too big. Use the --test_verbose_timeout_warnings command line option to see which ones these are.

We can also build again and get our benchmark binary:

$ bazel-7.3.1 build //...
Starting local Bazel server and connecting to it...
INFO: Analyzed 3 targets (100 packages loaded, 648 targets configured).
INFO: Found 3 targets...
INFO: Elapsed time: 5.809s, Critical Path: 2.78s
INFO: 60 processes: 16 internal, 44 linux-sandbox.
INFO: Build completed successfully, 60 total actions

We can find our output binary for the benchmark under the bazel-out symlink. We can run that and see:

bazel-out/k8-fastbuild/bin/source_code_benchmark |& tail --lines=+2
Running bazel-out/k8-fastbuild/bin/source_code_benchmark
Run on (16 X 6843.75 MHz CPU s)
CPU Caches:
  L1 Data 32 KiB (x8)
  L1 Instruction 32 KiB (x8)
  L2 Unified 1024 KiB (x8)
  L3 Unified 16384 KiB (x1)
Load Average: 1.74, 1.79, 1.25
***WARNING*** CPU scaling is enabled, the benchmark real time measurements may be noisy and will incur extra overhead.
***WARNING*** Library was built as DEBUG. Timings may be affected.
----------------------------------------------------------
Benchmark                Time             CPU   Iterations
----------------------------------------------------------
BM_do_something       3298 ns         3298 ns       214927

(If we actually were doing a benchmark then we would need to adjust for those warnings but for now I’ll leve that as an exercise for the reader)

Note the dir k8-fastbuild is a combination of our CPU architecture and the build type we ran. k8 is another name for x86-64 and fastbuild is the default build type for bazel.

And that’s it! We can now run our tests and benchmarks with bazel! Congrats! Now you can start exploring more about bazel and experimenting with it!

A few more things

Toolchain selection

A very frustrating thing when setting up build systems on multiple machines can be ensuring the same toolchain setup and that it is being used correctly. In comes toolchains_llvm as the perfect answer to this. By following details on their releases you can hook llvm into your MODULE.bazel and get a consistent toolchain on all machines.

Following the v1.2.0 release example, If I want to use llvm 18.1.8 I just need to add this to my MODULE.bazel file:

bazel_dep(name = "toolchains_llvm", version = "1.2.0")

# Configure and register the toolchain.
llvm = use_extension("@toolchains_llvm//toolchain/extensions:llvm.bzl", "llvm")
llvm.toolchain(
   llvm_version = "18.1.8",
)

use_repo(llvm, "llvm_toolchain")

register_toolchains("@llvm_toolchain//:all")

rebuild and now we’re using llvm!

compile_commands.json

I love LSP functionality, particularly clangd. clangd works best for me when I can generate a compile_commands.json for my codebases. Thankfully there is a really nice way to do this with bazel with: bazel-compile-commands-extractor

Following the instructions from the README.md we just need to do:

# Hedron's Compile Commands Extractor for Bazel
# https://github.com/hedronvision/bazel-compile-commands-extractor
bazel_dep(name = "hedron_compile_commands", dev_dependency = True)
git_override(
    module_name = "hedron_compile_commands",
    remote = "https://github.com/hedronvision/bazel-compile-commands-extractor.git",
    commit = "4f28899228fb3ad0126897876f147ca15026151e",
    # Replace the commit hash (above) with the latest (https://github.com/hedronvision/bazel-compile-commands-extractor/commits/main).
    # Even better, set up Renovate and let it do the work for you (see "Suggestion: Updates" in the README).
)

Then we just need to do bazel run @hedron_compile_commands//:refresh_all to get compile_commands.json for us to use!

fin

I hope you’ve enjoyed this quick bazel post and happy coding!

  • Tyler Sean Rau
Written on April 13, 2025