C++ and Bazel


  • Description: Bazel for C++ and Python — Bzlmod and legacy WORKSPACE, BUILD files, cc_* and py_* rules, visibility, external deps, configurations, remote caching, Starlark macros and rules
  • My Notion Note ID: K2A-B2-6
  • Created: 2020-01-17
  • Updated: 2026-04-30
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. Design Properties

Open-source build system from Google. Four properties distinguish it from CMake or Make:

  • Hermetic. Every input is declared — sources, headers, compilers, dependencies. Same inputs produce the same outputs.
  • Multi-language. C++, Java, Python, Go, JavaScript, and others share one action graph.
  • Incremental and parallel. Caching at the action level; minimal rebuilds.
  • Remote-cache and remote-execution friendly. Hermeticity is what makes shared caches across machines safe.

The trade-off: every #include and every dependency must be listed in a BUILD target before Bazel picks it up.

2. Workspace Setup

A Bazel project lives in a workspace, identified by a marker file at the root: MODULE.bazel (modern, Bzlmod) or WORKSPACE (legacy).

2.1 Bzlmod (Modern)

Bzlmod is the current dependency-management system. It's the default since Bazel 7.

MODULE.bazel:

module(name = "myapp", version = "0.1.0")

bazel_dep(name = "rules_cc",         version = "0.0.17")
bazel_dep(name = "googletest",       version = "1.15.2")
bazel_dep(name = "abseil-cpp",       version = "20240722.0")
bazel_dep(name = "protobuf",         version = "27.5")

Modules come from the Bazel Central Registry. bazel mod tidy updates lockfiles; bazel mod graph shows the resolved dependency tree.

2.2 WORKSPACE (Legacy)

Older projects pin dependencies imperatively in WORKSPACE (or WORKSPACE.bazel). The file declares the workspace name and then loads + invokes repository rules that fetch external code into local repositories you can reference with @name//....

# WORKSPACE
workspace(name = "myapp")

load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository", "new_git_repository")

# 1) Fetch a tarball over HTTP and extract it as an external repo
http_archive(
    name         = "com_google_googletest",
    urls         = ["https://github.com/google/googletest/archive/refs/tags/v1.15.2.tar.gz"],
    strip_prefix = "googletest-1.15.2",
    sha256       = "7b42b4d6ed48810c5362c265a17faebe90dc2373c885e5216439d37927f02926",
)

# 2) Pin a git checkout (use a commit, not a moving branch, for hermeticity)
git_repository(
    name   = "abseil",
    remote = "https://github.com/abseil/abseil-cpp.git",
    commit = "abc123def456...",
    shallow_since = "2024-08-01",
)

# 3) Same idea, but the upstream has no BUILD files -- you supply one
new_git_repository(
    name        = "lib_with_no_bazel",
    remote      = "https://github.com/example/lib.git",
    commit      = "...",
    build_file  = "//third_party:lib_with_no_bazel.BUILD",
)

# 4) Point at a sibling checkout on disk -- handy for local development
local_repository(
    name = "shared_protos",
    path = "../shared_protos",
)

# Many ecosystems require a two-step setup: fetch the rule repo, then call
# its setup macro to register toolchains and pull transitive deps.
load("@rules_cc//cc:repositories.bzl", "rules_cc_dependencies")
rules_cc_dependencies()

Once a repo is registered, refer to its targets with @<name>//path:target — e.g. @com_google_googletest//:gtest_main.

Why Bzlmod replaces this. WORKSPACE has no native version resolution: if two of your dependencies both pull in abseil at different commits, the first git_repository call wins and the second is silently ignored. Diagnosing the resulting "wrong header" or ABI mismatch is painful. Bzlmod's modules carry version metadata and the resolver picks one consistent version per module. For new projects, prefer Bzlmod; for an existing WORKSPACE-based project, the migration tool is bazel mod tidy after adding --enable_bzlmod.

WORKSPACE is deprecated and will be removed in a future Bazel release. As of Bazel 7+, Bzlmod is the default and you must opt in to WORKSPACE explicitly with --enable_workspace to keep it working.

3. BUILD Files

Each directory that contains buildable targets has a BUILD (or BUILD.bazel) file. Targets in path/to/pkg/BUILD are referenced by labels like //path/to/pkg:target_name.

myapp/
├── MODULE.bazel
├── BUILD
└── src/
    ├── BUILD
    ├── greeter.h
    ├── greeter.cc
    └── main.cc

4. Common C++ Rules

load(...) the rule from rules_cc (the registered C++ ruleset) at the top of each BUILD file. Bazel previously made these built-in; recent versions require the load.

load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_library", "cc_test")

4.1 cc_library

cc_library(
    name     = "greeter",
    srcs     = ["greeter.cc"],
    hdrs     = ["greeter.h"],
    deps     = ["@abseil-cpp//absl/strings"],
    copts    = ["-Wall", "-Wextra"],
    visibility = ["//visibility:public"],
)
  • srcs.cc/.cpp files; built into the library.
  • hdrs — public headers; consumers of this target may #include them.
  • deps — other cc_library targets needed at link time. Their public headers also become visible to this target.
  • copts, linkopts, defines — per-target flags.
  • includes — directories added to the include path of consumers (use sparingly; prefer strip_include_prefix / include_prefix).

The srcs vs hdrs distinction matters: only files in hdrs are visible to dependents.

4.2 cc_binary

cc_binary(
    name = "myapp",
    srcs = ["main.cc"],
    deps = [":greeter"],
)

bazel run //src:myapp -- arg1 arg2 builds and runs.

4.3 cc_test

cc_test(
    name = "greeter_test",
    srcs = ["greeter_test.cc"],
    deps = [
        ":greeter",
        "@googletest//:gtest_main",
    ],
)

Tests are pass/fail based on exit code. Run with bazel test //src:greeter_test or bazel test //src/... for everything under src/. Add size = "small" / "medium" / "large" to advise resource expectations.

5. Beyond C++: Python and Other Languages

The same workspace can hold targets in any language Bazel has rules for — Python, Go, Java, Rust, JavaScript. The workflow mirrors C++: load a ruleset, call its rules from BUILD files, build and test with the same bazel build / bazel test commands.

Python uses rules_python. Add the dep to MODULE.bazel:

bazel_dep(name = "rules_python", version = "0.40.0")

# Optional: pull a specific Python interpreter and pin it for reproducibility
python = use_extension("@rules_python//python/extensions:python.bzl", "python")
python.toolchain(python_version = "3.12")

Then in a BUILD file:

load("@rules_python//python:defs.bzl", "py_binary", "py_library", "py_test")

py_library(
    name = "greeter",
    srcs = ["greeter.py"],
    visibility = ["//visibility:public"],
)

py_binary(
    name = "myapp",
    srcs = ["main.py"],
    main = "main.py",
    deps = [":greeter"],
)

py_test(
    name = "greeter_test",
    srcs = ["greeter_test.py"],
    deps = [":greeter"],
)

Run a Python test the same way as a C++ test:

bazel test //src:greeter_test
bazel test //src:greeter_test --test_output=streamed   # see prints in real time

For pip dependencies, rules_python provides a pip extension that reads a requirements_lock.txt and exposes each package as a @pip_deps//<pkg> target you can list in deps:

# MODULE.bazel
pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip")
pip.parse(hub_name = "pip_deps", python_version = "3.12",
          requirements_lock = "//:requirements_lock.txt")
use_repo(pip, "pip_deps")
# BUILD
py_test(
    name = "uses_numpy_test",
    srcs = ["uses_numpy_test.py"],
    deps = ["@pip_deps//numpy", "@pip_deps//pytest"],
)

The point of doing Python in Bazel is the same as doing C++ in Bazel: hermetic, cached, parallel, and shareable across machines via remote cache. A monorepo can have a C++ binary and the Python integration test that exercises it sharing one bazel test //... invocation.

Other languages follow the same pattern: rules_go for Go, rules_rust for Rust, rules_jvm_external plus the built-in java_* rules for Java/Kotlin/Scala, and so on.

6. Labels and Visibility

A label is a global name for a target.

Form Meaning
//src:greeter Target greeter in package src
//src Shorthand for //src:src
:greeter Same package as the current BUILD
@abseil-cpp//absl/strings Target strings in module abseil-cpp

Visibility controls who can depend on a target. By default a target is package-private; lift it explicitly:

cc_library(
    name = "internal_helpers",
    srcs = ["helpers.cc"],
    hdrs = ["helpers.h"],
    visibility = [
        "//src:__pkg__",            # only the //src package
        "//src/utils:__subpackages__",  # //src/utils and below
        "//visibility:public",      # everywhere
    ],
)

__pkg__ (just this package), __subpackages__ (this package and below), and explicit package lists are the common patterns. //visibility:public is a deliberate widening — use it on stable public APIs only.

7. External Dependencies

Under Bzlmod, add a bazel_dep to MODULE.bazel and reference its public targets via @module_name//path:target. The Bazel Central Registry has most of the popular C++ libraries (Abseil, Protobuf, gRPC, Boost, Eigen, googletest, ...).

For things not in the registry, use a non-module repo via module_extension + http_archive (modern) or http_archive directly in WORKSPACE (legacy).

# Override a module to point at a local checkout (useful for development)
local_path_override(
    module_name = "abseil-cpp",
    path = "../abseil-cpp",
)

# Override to a specific git revision
git_override(
    module_name = "googletest",
    remote      = "https://github.com/google/googletest.git",
    commit      = "abc123...",
)

8. Configurations and Platforms

--config=NAME selects a named bundle of flags from .bazelrc:

# .bazelrc
build:opt   --compilation_mode=opt --copt=-O3 --copt=-DNDEBUG
build:dbg   --compilation_mode=dbg --copt=-O0 --copt=-g
build:asan  --copt=-fsanitize=address --linkopt=-fsanitize=address
build:tsan  --copt=-fsanitize=thread  --linkopt=-fsanitize=thread

# Per-platform toolchain
build:linux_arm64 --platforms=//build/platforms:linux_arm64

Then:

bazel build --config=opt //...
bazel build --config=asan //src:greeter_test
bazel build --config=linux_arm64 //...

Platforms (@platforms//os:linux, @platforms//cpu:aarch64, ...) drive toolchain selection and select()-based conditional sources/deps:

cc_library(
    name = "platform_specific",
    srcs = ["common.cc"] + select({
        "@platforms//os:linux":   ["linux.cc"],
        "@platforms//os:macos":   ["macos.cc"],
        "//conditions:default":   ["other.cc"],
    }),
)

9. Common Commands

# Build everything in the workspace
bazel build //...

# Build one target
bazel build //src:myapp

# Run a binary target
bazel run //src:myapp -- arg1 arg2

# Run all tests under a directory
bazel test //src/...

# Query: who depends on this target?
bazel query "rdeps(//..., //src:greeter)"

# Query: what does this target depend on (transitively)?
bazel query "deps(//src:myapp)"

# Show the action graph for a target (debugging)
bazel aquery //src:myapp

# Discard everything not actively in use
bazel clean

# Wipe everything including external repos and cached downloads
bazel clean --expunge

# Show effective flags
bazel info

bazel build //... over a large repo can be slow on first run but very fast on incremental rebuilds, and even faster across machines if remote caching is configured.

10. Remote Caching

Remote caching lets every build machine share action results — if anyone in the cluster has built a particular (inputs → outputs) action before, every other machine gets it for free.

# In .bazelrc or on the command line
build --remote_cache=grpcs://my-cache.example.com:443
build --remote_instance_name=myproject

# Read-only cache (don't upload anything; just consume)
build --remote_upload_local_results=false

Common backends: bazel-remote (open-source self-hosted), Google Cloud RBE, BuildBuddy, EngFlow. Any service implementing the Remote Execution API works.

A close cousin, remote execution (--remote_executor=...), runs the actions on remote workers entirely. Setup is more involved (toolchain definitions need to match the worker environment); the speedup on cold builds can be order-of-magnitude.

11. Macros and Rules (Starlark)

Bazel's extension language is Starlark — a Python-subset, deterministic by design (no I/O, no recursion, no while-loops with side effects).

Macros are functions that emit other rule calls. Use them to wrap a common pattern:

# tools/proto.bzl
# In Bazel 8+, proto_library / cc_proto_library moved out of Bazel
# core into the protobuf repo; load them explicitly.
load("@protobuf//bazel:proto_library.bzl", "proto_library")
load("@protobuf//bazel:cc_proto_library.bzl", "cc_proto_library")

def cc_proto_lib(name, srcs, deps = []):
    """Wraps proto_library + cc_proto_library + a stable target name."""
    proto_library(
        name = name + "_proto",
        srcs = srcs,
        deps = deps,
    )
    cc_proto_library(
        name = name,
        deps = [":" + name + "_proto"],
    )
# BUILD
load("//tools:proto.bzl", "cc_proto_lib")
cc_proto_lib(name = "user", srcs = ["user.proto"])

Rules are full custom build steps. They define inputs, outputs, and the action(s) that produce them. Use rules when a macro isn't enough — a custom code generator, a one-off tool integration, etc.

# tools/codegen.bzl
def _codegen_impl(ctx):
    out = ctx.actions.declare_file(ctx.attr.name + ".cc")
    ctx.actions.run(
        executable = ctx.executable._gen,
        arguments  = [ctx.file.src.path, out.path],
        inputs     = [ctx.file.src],
        outputs    = [out],
        mnemonic   = "Codegen",
    )
    return [DefaultInfo(files = depset([out]))]

codegen = rule(
    implementation = _codegen_impl,
    attrs = {
        "src":  attr.label(allow_single_file = True, mandatory = True),
        "_gen": attr.label(executable = True, cfg = "exec",
                           default = "//tools:codegen"),
    },
)

A custom rule is also where providers come in — typed data structures returned to dependents (e.g., CcInfo for C++ headers/libs). Most projects can get a long way with just macros and the built-in rules.

12. References