Last year I worked doing some build system improvements on a codebase which heavily used Bazel. This was my first time using it in earnest and I was struck by how difficult it is to sum up. I wanted to write an article to paint a picture of what I ran into using Bazel.
TL;DR, my conclusion:
As you’ve now seen, Bazel is quite different to other build systems. It’s designed for large organisations with very large, multi-language application or service codebases, and its features heavily reflect that. On the other hand, it’s poorly-suited to small and medium teams, or libraries with many different users and diverse development, such as in the open-source ecosystem.
The amount of idiosyncracies and the low quality of documentation make it hard for me to recommend unless you have enough people to wrangle it and who can reap enough of the upsides to be worth it. But if you can, you’ll probably find it best-in-class.
The good
Bazel has a very powerful build language, allowing many different programming languages and tools to be combined together in one build graph. This is a huge advantage, as many software projects inevitably involve multiple languages, and usually their main option is to glue each of their build tools together with wrapper shell scripts or something. Instead, Bazel unifies everything. You get all of Bazel’s features across your whole codebase.
Bazel implements remote execution and caching, which can provide enormous build speed-ups. The API, usually called RBE or REAPI, is designed to be highly scalable and several production-grade open-source server implementations exist.
Bazel has support for downloading files and is designed to support this for retrieving dependencies and build tools, enabling consistent, “hermetic” builds (where the build is done with its own specified version of all dependencies instead of relying on what’s installed on the system).
Bazel is designed to scale to a degree basically no other build tool is. It is designed for codebases that are so large that even parsing the build definitions takes a significant amount of time; it can start build actions before it’s finished doing so.
Bazel has support for running complex queries across its dependency graph, which allows, for example, writing developer tools to calculate which targets changed between Git commits, or:
What C++ libraries do the foo tests depend on that the //foo production binary does not depend on?
bazel query 'kind("cc_library", deps(kind(".*test rule", foo/...)) except deps(//foo))'
Bazel can run tests and includes tests in the dependency graph, so it can cache test results and only run tests that depend on changed code. Compounding with remote caching, this can massively reduce test times in development and in CI.
Bazel supports various levels of sandboxing for build actions, and by default runs them separate from your codebase directory. This prevents a common cause of woe with other tools where a build action reads a file that it didn’t declare as an input and so doesn’t re-run when it should.
This section is shorter in word count than the next, but these features are truly rare in the world of build systems, and even when they can be realised (such as distcc for remote C compilation), Bazel maintains the advantage of being multi-language.
The bad
Atypical developer experience
To leverage the power of Bazel, some programming languages have to be used in very atypical ways. For example, the Python ruleset rules_python is nothing like normal usage of pip and venvs; Bazel eschews the concept of a Python environment and instead encourages you to treat your Python libraries and packages as individual Bazel targets, which can then be combined in arbitrary ways. Then, when building or running a target, Bazel constructs its own ad-hoc Python environment out of the required files. It’s not comparable at all with normal Python development and will not work easily with most Python IDEs and tools.
Low-quality documentation
The Bazel ecosystem suffers from very poor documentation.
This is a complaint one hears a lot when Bazel is mentioned, particularly that it lacks good tutorials and examples for beginners. This is true, but I want to stress that, even more crucially, it lacks detailed reference information for many aspects of its behaviour.
What does bazel build do, in detail, from a user’s point of view? There’s some information, and you can figure some things out through use, but there’s no central reference point.
What does bazel run do? You may ask why Bazel has a run command at all; it’s because it has a concept of runfiles, which are run-time file dependencies of targets. When Bazel runs a target, it gathers all those runfiles together into its own execution environment. This is pretty useful, but I can’t find the details anywhere. The only place I’ve seen it introduced is the user guide entry for --build_runfiles_links:
When tests (or applications) are executed, their run-time data dependencies are gathered together in one place. Within Bazel’s output tree, this “runfiles” tree is typically rooted as a sibling of the corresponding binary or test. During test execution, runfiles may be accessed using paths of the form
$TEST_SRCDIR/canonical_repo_name/packagename/filename.
Unfortunately though, this isn’t correct, because
- runfiles are not always made into a directory tree; sometimes they take the form of a manifest file, so you can’t always access them in this way
- canonical repo names are not API-stable, so it’s not correct to write code which depends on them and Bazel developers discourage you from doing this
What you are supposed to do is use a runfiles library for your language. rules_python has API documentation for its one, but barely any other information. The shell rules have an API too, but it’s awkward, requiring copying a preamble into your shell scripts. For the very basic task of locating data files, this all feels way too complicated. You ask yourself: is this really the way the Bazel authors intended us to do this?
There’s a GitHub issue requesting documentation for runfiles that was open for more than 6 years. There was a very basic page added recently, but there’s a lot missing. The best resource I’ve found is this internal guide from Google’s Fuchsia project: build/bazel/BAZEL_RUNFILES.md
How do you, in general, get a runnable target out of Bazel’s environment so it can be distribued and run standalone? I’ve heard of teams just using bazel run in production to avoid having to figure that out.
A Bazel rule’s main behaviour is to define output files and optionally some providers, but this information is rarely documented in detail. The only documentation for the cc_binary rule is “It produces an executable binary.” Surely that’s not all we can say. How is the compiler invoked? If you’re more experienced with Bazel you’ll know the answer to that question involves the toolchain system—this is the perfect place to mention it.
In rules_python, I struggled to find out basic things like how to import a py_library target in Python. If you look up py_library, the only documentation is:
Creates an executable Python program.
This is the public macro wrapping the underlying rule. Args are forwarded on as-is unless otherwise specified. See py_library (link) for detailed attribute documentation.
“Creates an executable Python program” … it’s a copy-paste of the py_binary documentation (which is itself not helpful; what does “an executable Python program” mean?) If you follow the link, you do get detailed attribute documentation, but you still get nothing telling what the rule is doing. The docs feel like a loose collection of notes for people who already know how it works.
Bazel 9.0 has been released, but documentation has not been updated for it. For example, a major change in 9.0 was the removal of all the built-in native build rules, but the Bazel rules reference (one of the first places a user might go to for information) still wrongly says:
Native rules ship with the Bazel binary and do not require a load statement. Native rules are available globally in BUILD files.
rules_cc is one such ruleset which is no longer built-in to Bazel, but has no documentation of its own; confusingly, it all still lives in the main docs, so it appears that it hasn’t been migrated at all. This is especially confusing for those parts of the API which have only been partly migrated. For example, in 9.0 there is still a built-in module called cc_common, however there is also a cc_common module you can import from rules_cc. There are contents which are only in one but not the other, but because there’s only one set of documentation for both, you can’t learn this. It’s not even mentioned in the release notes! I had to search through the rules_cc source code to figure out it had changed.
Build configuration and the module system
Bazel’s configuration system is a bit strange. You configure Bazel rules via select(), which is like an if-elif-else expression. The conditions for selects must be one of the special types of target which have a kind of Boolean truth value associated with them: either platform constraint values or “config settings”. You can create config settings from built-in Bazel command-line flags like --compilation_mode, or from custom flags called “build settings”. So, there’s four different but related concepts: selectable targets, constraints, config settings, and build settings. This is pretty confusing and I’m sure the naming and design here can be improved. (“Selectable target” is just a name I made up because I don’t think there is an official one.)
Compared to other build systems, Bazel’s configuration is limited because it’s impossible to set build settings in definitions. You have to use command-line flags, which can only be set at the top-level Bazel module, preventing each module from abstracting details about its dependencies; when you import something, you have to specify necessary configuration for your whole dependency tree.
Fortunately or unfortunately, this doesn’t come up much, since in my experience most modules in the Bazel Central Registry build with a fixed configuration. If it’s not the configuration you want, you have to do arduous patching with module overrides. In my opinion, this makes the Bazel Central Registry very unattractive to adopt, except for the smallest and least opinionated of projects (and in that case, why are you using Bazel anyway?) It seems easier in the long-term to do everything yourself the pre-module way (repository rules).
Equivalently, this also makes Bazel a very poor choice of build system for an open-source library, where you’d want consumers to easily control the configuration they are using.
Most frustratingly of all, Bazel depends internally on several modules, notably protobuf. Therefore, it is simply not possible at all to use a version of protobuf different than the one Bazel depends on (without writing custom rules and patching all your dependencies to use them).
Conclusion
As you’ve now seen, Bazel is quite different to other build systems. It’s designed for large organisations with very large, multi-language application or service codebases, and its features heavily reflect that. On the other hand, it’s poorly-suited to small and medium teams, or libraries with many different users and diverse development, such as in the open source ecosystem.
The amount of idiosyncracies and the low quality of documentation make it hard for me to recommend unless you have enough people to wrangle it and who can reap enough of the upsides to be worth it. But if you can, you’ll probably find it best-in-class.
