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. 4 distinguishing properties:

  • Hermetic — every input declared (sources, headers, compilers, deps). Same inputs → same outputs.

  • Multi-language — C++, Java, Python, Go, JS, etc. share one action graph.

  • Incremental + parallel — action-level caching, minimal rebuilds.

  • Remote-cache + remote-execution friendly — hermeticity makes shared caches across machines safe.

  • Trade-off: every #include + dependency must be in a BUILD target.

2. Workspace Setup

  • Bazel project = workspace. Identified by root marker: MODULE.bazel (modern, Bzlmod) or WORKSPACE (legacy).

2.1 Bzlmod (Modern)

  • Current dependency-management system. 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 from Bazel Central Registry.
  • bazel mod tidy — updates lockfiles. bazel mod graph — resolved tree.

2.2 WORKSPACE (Legacy)

  • Older projects pin deps imperatively in WORKSPACE (or WORKSPACE.bazel).
  • Declares workspace name + invokes repository rules fetching external code into local repos accessed via @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 registered, refer with @<name>//path:target — e.g. @com_google_googletest//:gtest_main.

  • Why Bzlmod replaces this:

    • WORKSPACE has no native version resolution. 2 deps pulling abseil at different commits → first git_repository wins silently.
    • "Wrong header" / ABI mismatches painful to diagnose.
    • Bzlmod's modules carry version metadata; resolver picks one consistent version per module.
  • New projects → Bzlmod. Existing → migrate via bazel mod tidy after --enable_bzlmod.

  • WORKSPACE deprecated, removed in future Bazel. Bazel 7+ → must opt in with --enable_workspace.

3. BUILD Files

  • Each directory with buildable targets has BUILD (or BUILD.bazel).
  • Targets in path/to/pkg/BUILD 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(...) rule from rules_cc at top of each BUILD. Bazel previously built-in; recent versions require 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; built into the library.
  • hdrs — public headers; consumers may #include.
  • deps — other cc_library targets needed at link time. Public headers become visible.
  • copts, linkopts, defines — per-target flags.
  • includes — dirs added to consumers' include path (use sparingly; prefer strip_include_prefix / include_prefix).
  • srcs vs hdrs matters: only hdrs files visible to dependents.

4.2 cc_binary

cc_binary(
    name = "myapp",
    srcs = ["main.cc"],
    deps = [":greeter"],
)
  • bazel run //src:myapp -- arg1 arg2 builds + runs.

4.3 cc_test

cc_test(
    name = "greeter_test",
    srcs = ["greeter_test.cc"],
    deps = [
        ":greeter",
        "@googletest//:gtest_main",
    ],
)
  • Pass/fail by exit code.
  • Run: bazel test //src:greeter_test or bazel test //src/... for all under src/.
  • Add size = "small" / "medium" / "large" for resource hints.

5. Beyond C++: Python and Other Languages

  • Same workspace, any language Bazel has rules for — Python, Go, Java, Rust, JS.
  • Workflow mirrors C++: load ruleset, call rules from BUILD, build/test with bazel build / bazel test.

Python uses rules_python. Add 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")

In BUILD:

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 Python test same as C++:
bazel test //src:greeter_test
bazel test //src:greeter_test --test_output=streamed   # see prints in real time
  • pip deps: rules_python pip extension reads requirements_lock.txt, exposes each pkg as @pip_deps//<pkg>:
# 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"],
)
  • Same Bazel value for Python as C++: hermetic, cached, parallel, shareable via remote cache.
  • Monorepo can have C++ binary + Python integration test exercising it, sharing one bazel test //....
  • Other languages: rules_go, rules_rust, rules_jvm_external + built-in java_*, etc.

6. Labels and Visibility

  • Label = 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. Default = package-private. Lift 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 + below), explicit package lists — common patterns.
  • //visibility:public = deliberate widening — use only on stable public APIs.

7. External Dependencies

  • Bzlmod: bazel_dep in MODULE.bazel, reference public targets via @module_name//path:target.
  • Bazel Central Registry has most popular C++ libs (Abseil, Protobuf, gRPC, Boost, Eigen, googletest, ...).
  • Non-registry: 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
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 + 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 large repo: slow first run, very fast incremental, faster still cross-machine with remote caching.

10. Remote Caching

  • Shared cache across machines — if anyone in cluster built a particular (inputs → outputs) action before, all others get it 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 Remote Execution API works.

  • Remote execution (--remote_executor=...) — runs actions on remote workers. More setup (toolchain defs match worker env); order-of-magnitude speedup on cold builds.

11. Macros and Rules (Starlark)

  • Extension language = Starlark — Python subset, deterministic by design (no I/O, no recursion, no side-effect while-loops).

Macros — functions emitting other rule calls. Wrap common patterns:

# 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 — full custom build steps. Define inputs, outputs, action(s) producing them.

  • Use when macro isn't enough — custom code generator, one-off tool integration.
# 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"),
    },
)
  • Custom rules → providers: typed data structures returned to dependents (e.g., CcInfo for C++ headers/libs).
  • Most projects get far with just macros + built-in rules.

12. References