TL;DR — Bazel provides a deterministic, cache‑able build graph that works across languages and repositories, enabling fast incremental builds and reproducible CI pipelines even in large monorepos.

Why Bazel?

Modern development teams often juggle multiple languages, large codebases, and the pressure to ship features quickly. Traditional build systems such as Make, Gradle, or npm scripts become brittle as the project grows: they lack hermetic execution, struggle with cross‑language dependencies, and produce nondeterministic artifacts that break CI caches. Bazel, open‑source since 2015 and originally developed at Google, addresses these pain points by treating the build as a first‑class dataflow graph.

At its core, Bazel asks two questions for every artifact: what inputs produced it? and can those inputs be executed in a hermetic, reproducible environment? If the answer is yes, Bazel can cache the result keyed by a hash of the inputs, avoiding re‑execution on subsequent builds. This principle scales from a single developer’s laptop to a fleet of CI agents spanning dozens of machines.

Key motivations for adopting Bazel include:

  • Multi‑language support – a single build graph can compile Go, Rust, Java, Python, and more, using language‑specific rules that enforce hermeticity.
  • Remote execution – build actions can be off‑loaded to powerful remote servers, reducing local hardware constraints.
  • Fine‑grained caching – the action graph caches at the level of individual compiler invocations, linker steps, and test runs.
  • Correctness by construction – Bazel’s Starlark‑based BUILD files make dependencies explicit, eliminating the “works on my machine” syndrome.

Core Bazel Concepts

Workspace and Packages

Every Bazel project starts with a workspace directory containing a WORKSPACE file. This file may declare external dependencies (e.g., Maven artifacts, Git repos) and configure flags for remote execution. Under the workspace root, packages are directories terminated by a BUILD file. Each BUILD file describes the set of targets—executable programs, libraries, tests, or data files—that the package contributes.

Targets and Rules

A target is identified by a label of the form //package/path:target_name. Rules are the Starlark functions that produce targets. For example, go_library, py_binary, and cc_binary are built‑in rules that know how to compile Go, Python, and C++ code respectively. Custom rules can be written in Starlark to encapsulate domain‑specific build logic.

# BUILD.bazel for a simple Go binary
go_binary(
    name = "myapp",
    srcs = ["main.go"],
    deps = [
        "//libs/util",
    ],
)

The go_library rule, for instance, invokes the Go compiler inside a sandboxed environment, ensuring that the build outcome depends solely on the declared srcs, deps, and the Go version specified in the rule’s go_version attribute.

Visibility and Dependencies

One of Bazel’s most powerful features is visibility. By default, a target can only see its own deps; other packages must explicitly declare visibility = ["//package:target"] to expose themselves. This granular control prevents accidental coupling and forces developers to think deliberately about which parts of the codebase are public APIs.

Incremental Builds and the Action Graph

Bazel’s performance hinges on the action graph, a directed acyclic graph where nodes are actions (compiler invocations, linker steps, test executions) and edges represent input/output relationships. When a source file changes, Bazel recomputes only the affected actions, re‑uses cached results for unchanged inputs, and skips everything else.

Hermetic Execution

To guarantee cacheability, Bazel runs actions hermetically: the sandbox contains exactly the files and environment variables needed for that action, nothing more. This means the compiler sees a deterministic filesystem, and the resulting binary is reproducible across any machine that can run the same action. Hermetic execution also enables remote execution: the action description is sent to a remote executor, which runs the sandboxed command and returns the output hashes.

Example: Remote Caching in CI

Consider a CI pipeline that runs bazel test //... on every pull request. With remote caching enabled, the first run compiles all necessary targets and uploads the action results to a cloud cache (e.g., Bazel Cloud or a self‑hosted gRPC cache). On subsequent PRs, Bazel downloads the cached actions, skipping the compile step entirely. In practice, teams report 70‑90 % reduction in build times after migrating to remote caching, especially for large monorepos with many language runtimes.

Architecture Patterns in Production

Monorepo Layout

Many organizations adopt a monorepo strategy, housing multiple services, libraries, and infrastructure code in a single repository. Bazel excels here because the action graph can share cache entries across packages. A typical layout might look like:

repo/
  WORKSPACE
  modules/
    auth/
      BUILD.bazel
      main.go
    billing/
      BUILD.bazel
      main.py
  infra/
    docker/
      BUILD.bazel
    k8s/
      charts/

Each module declares its own go_library, py_library, or sh_binary targets, while shared utilities live in a common //tools package.

Multi‑Language Integration

A realistic scenario involves a Go microservice that calls a Python data‑processing pipeline. Bazel’s rules can orchestrate both runtimes:

# BUILD.bazel for the Go service
go_binary(
    name = "service",
    srcs = ["main.go"],
    deps = [
        "//modules/auth",
        "//infra/docker:build",
    ],
)

# BUILD.bazel for the Python pipeline
py_binary(
    name = "processor",
    srcs = ["processor.py"],
    deps = [
        "//deps/requests",
    ],
)

The go_binary can depend on //infra/docker:build, which itself builds a Docker image using a docker_image rule. The Python binary can depend on a third‑party package hosted on PyPI, fetched hermetically by Bazel’s external toolchain.

CI Integration Patterns

Integrating Bazel with common CI systems (GitHub Actions, GitLab CI, CircleCI) typically follows these steps:

  1. Checkout the repository and ensure the Bazel version is pinned (e.g., via asdf or a Docker image).
  2. Run bazel build //... to compile all targets. The action graph leverages local caches; if remote caching is configured, it will also pull previously cached actions.
  3. Execute bazel test //... to run the test suite. Bazel’s test rule captures stdout, stderr, and exit codes, producing JUnit‑compatible XML reports that CI systems can ingest.
  4. Publish artifacts (Docker images, npm packages, etc.) using Bazel’s docker_image and maven_publish rules, which embed the exact commit hash and build metadata.

A concrete example of a GitHub Actions workflow that uses Bazel:

name: Bazel CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up Bazel
        uses: bazelbuild/setup-bazel@v2
        with:
          bazel-version: "6.0.0"
      - name: Build and test
        run: |
          bazel build //...
          bazel test //...

Migration Strategies

Adopting Bazel in an existing codebase need not be an all‑or‑nothing rewrite. Teams often employ a gradual migration approach:

  1. Identify a low‑risk, self‑contained subsystem (e.g., a utility library or a small service) and create a BUILD file for it.
  2. Run bazel build //path/to:target in isolation to verify that the rule works and that the output matches the existing build artifact.
  3. Introduce remote caching for the new target, observing the speed gain.
  4. Expand incrementally, moving more packages under Bazel’s umbrella while keeping legacy Makefiles or Gradle builds for untouched areas.
  5. Use bazel run to execute scripts that bridge Bazel and existing tooling, such as invoking go test inside a Bazel sandbox.

A common pitfall is trying to migrate the entire repository in a single sprint, which often leads to broken builds and developer frustration. Instead, treat Bazel as a layered improvement: each successfully migrated package yields immediate CI speedups and serves as a case study for the next wave.

Key Takeaways

  • Bazel’s action graph provides deterministic, cache‑able builds across multiple languages and repositories.
  • Hermetic execution and remote execution are the twin pillars that enable scalability from a laptop to a fleet of CI agents.
  • The Starlark BUILD language enforces explicit dependencies and visibility, reducing hidden coupling.
  • Monorepo layouts benefit from shared caches, but require careful organization of packages and rules.
  • Gradual migration, starting with a single package, yields the lowest risk and fastest ROI.
  • Integrating Bazel with CI pipelines is straightforward when using built‑in rules for Docker, JUnit reports, and artifact publishing.

Further Reading