Most build systems blur together “figuring out what to build” and “building it.” Bazel separates these into three strict, sequential phases. This separation is what makes caching, remote execution, and correctness guarantees possible.

Phase 1: Loading — “What targets exist?”

Bazel starts by reading your BUILD files to discover what targets exist and how they relate. This is purely about structure — it’s building a graph of names and declared relationships, not figuring out how to compile anything.

Starting from the requested target (e.g., //rust/my_module:my_module), Bazel finds the relevant BUILD.bazel file and executes it as Starlark code, top to bottom. Every function call like rust_shared_library(name = "my_module", ...) registers a target — it’s saying “a target with this name and these attributes exists.”

If the BUILD file calls a macro (a plain Starlark function that itself calls rule functions — see Bazel Rules and Macros), the macro body executes now, expanding into one or more target registrations. glob() calls also execute now, matching filesystem patterns like glob(["src/**/*.rs"]) to produce file lists.

Here’s what a BUILD file looks like in practice:

# rust/my_module/BUILD.bazel
load("@rules_rust//rust:defs.bzl", "rust_shared_library")
 
rust_shared_library(
    name = "my_module_rs",
    srcs = glob(["src/**/*.rs"]),        # glob() runs now — resolves to ["src/lib.rs"]
    deps = ["@crate_index//:pyo3"],      # a label reference — just a string at this point
    edition = "2021",
    visibility = ["//python:__subpackages__"],
)

When Bazel executes this, it registers one target: //rust/my_module:my_module_rs of type rust_shared_library, with the given attributes. That’s it — no compilation, no toolchain resolution, just “this target exists with these properties.”

The output is a target graph: nodes are targets (with names, rule types, and attribute values), edges are the dependency relationships declared by attributes like deps, srcs, data.

What you CANNOT do during Loading — and this is crucial to understand why:

  • Cannot access toolchains. You don’t know what platform you’re building for yet — that’s a concern for the next phase.
  • Cannot read file contents. glob() can list filenames, but can’t open files. Loading must be fast, and file I/O is slow.
  • Cannot inspect the build configuration (e.g., “am I building in opt mode?”). Configuration is resolved in Analysis.
  • Cannot run external commands. No shelling out.

These restrictions exist because Loading must be fast and deterministic. Bazel aggressively caches the target graph — it only re-executes a BUILD file if the file itself changes. Any non-determinism (reading file contents, running commands) would break this caching.

Common Loading bugs:

  • A macro that tries to choose different deps based on the OS. This is a configuration concern — use select() in attribute values instead, which defers the choice to Analysis.
  • glob() silently matching zero files because the pattern is wrong — no error, just an empty list.
  • Circular load() dependencies between .bzl files.

Phase 2: Analysis — “How should each target be built?”

Now Bazel knows what targets exist. Next it needs to figure out how to build them. For every target that needs to be built, Bazel calls the target’s rule implementation function — the Starlark function that defines the rule’s build logic.

The key constraint: Bazel processes targets bottom-up (leaves first, then their dependents). This means when Bazel analyzes target A, all of A’s dependencies have already been analyzed. Their implementation functions already ran, and they already returned metadata about themselves. This metadata is called providers — typed structs carrying information like “my compiled output is this .rlib file, my crate name is pyo3.” (Full details in Bazel Providers and depset.)

For each target, Bazel creates a context object (ctx) and calls the implementation function. That function does three things:

  1. Reads metadata from dependencies. Each dependency already returned providers. The current target reads them to learn what files and flags it needs.

  2. Declares actions. An action is a build command — “run rustc with these flags, reading these input files, producing this output file.” Nothing executes yet — this is just planning. The function tells Bazel what commands to run, but doesn’t run them.

  3. Returns its own providers. Structured metadata that targets depending on this target will read in turn. The information flows upward through the graph.

Here’s a simplified trace of what happens inside rules_rust when it analyzes //rust/my_module:my_module_rs:

def _rust_shared_library_impl(ctx):
    # 1. Read from toolchain (resolved by Bazel for this platform)
    rust_tc = ctx.toolchains["@rules_rust//rust:toolchain_type"]
    rustc = rust_tc.rustc  # File: the rustc binary
 
    # 2. Read metadata from dependencies (they were already analyzed)
    dep_rlibs = []
    for dep in ctx.attr.deps:
        crate_info = dep[CrateInfo]         # CrateInfo is a provider (typed struct)
        dep_rlibs.append(crate_info.output)  # the compiled .rlib file
 
    # 3. Declare the output (doesn't exist yet — just a plan)
    output = ctx.actions.declare_file("libmy_module_rs.so")
 
    # 4. Declare the action (the command to run later, during Execution)
    ctx.actions.run(
        executable = rustc,
        arguments = ["--crate-type=cdylib", "--edition=2021",
                     "-o", output.path, ctx.files.srcs[0].path],
        inputs = ctx.files.srcs + dep_rlibs + [rustc],
        outputs = [output],
    )
 
    # 5. Return providers (metadata for targets that depend on us)
    return [
        DefaultInfo(files = depset([output]), runfiles = ctx.runfiles(files = [output])),
        CrateInfo(name = "my_module", type = "cdylib", output = output, ...),
    ]

Nothing compiled. This function built a plan: what command to run, what files it reads, what files it produces, and what metadata to pass upstream.

The output is an action graph: nodes are actions (commands to run), edges are file dependencies between them (if Action B needs a file that Action A produces, A must run before B).

What you CAN do during Analysis (that you couldn’t during Loading):

  • Access toolchains — Bazel has now resolved which compiler to use for the current platform. See Bazel Toolchains and Platforms.
  • Read the build configuration — platform, compilation mode, feature flags. This is when select() expressions get resolved.

Common Analysis bugs:

  • Missing provider. You put a py_library in the deps of a rust_library. The Rust rule tries to read Rust-specific metadata from it, but a Python target doesn’t have that. Error.
  • Toolchain not found. No registered compiler matches the current platform.
  • Undeclared output. You forgot to tell Bazel about a file your action will create.

Phase 3: Execution — “Actually build it.”

Now Bazel has a plan (the action graph). It walks the graph in dependency order and actually runs the commands. For each action:

  1. Check the cache. Bazel hashes the action’s inputs (file contents + command line + environment variables). If a cached output exists with this hash, the action is skipped entirely — Bazel uses the cached output.

  2. Set up the sandbox. Bazel creates a temporary directory containing only the files declared as inputs to this action. Not your home directory, not /usr/lib, not random files in the workspace — only what the action explicitly said it needs.

  3. Run the command. Execute the tool (e.g., rustc) inside the sandbox.

  4. Collect outputs. Move the declared output files to Bazel’s output tree (bazel-out/...). If the action didn’t produce a file it promised, that’s an error.

  5. Update the cache. Store outputs keyed by the action hash, so future builds skip this action if nothing changed.

For our Rust example, the actual command Bazel runs looks like:

# Inside a sandbox directory containing ONLY declared inputs:
/sandbox/external/rust_toolchain/bin/rustc \
    --crate-type=cdylib \
    --edition=2021 \
    --extern pyo3=/sandbox/bazel-out/.../libpyo3.rlib \
    -o /sandbox/bazel-out/.../libmy_module_rs.so \
    /sandbox/rust/my_module/src/lib.rs

The sandbox is the key to Bazel’s correctness. If an action succeeds in the sandbox, it will succeed on any machine with the same inputs. If you forgot to declare an input file, the sandbox simply won’t contain it, and the action fails loudly — rather than silently depending on something Bazel doesn’t track. This is also what makes remote execution work: Bazel can ship the sandbox contents to a remote worker that has nothing installed.

Common Execution bugs:

  • Compilation errors. rustc fails because your code has a type error. Normal.
  • Missing input. You forgot to declare a header file as an input. The sandbox doesn’t have it, the compiler fails with “file not found.”
  • Non-hermetic action. Your rule hardcodes /usr/bin/python3. Works on your machine; doesn’t exist in the sandbox or on a remote worker. Use a Bazel-managed toolchain instead. See Bazel Toolchains and Platforms.
  • Wrong output path. You produced my_module.so but Python expects my_module.cpython-311-x86_64-linux-gnu.so.

Cheat Sheet

QuestionLoadingAnalysisExecution
What code runs?BUILD files, macros, glob()Rule implementation functionsExternal tools (rustc, gcc, python)
What is produced?Target graphAction graphArtifact files
Can I read file contents?NoNoYes (the tools can)
Can I run external commands?NoNoYes
Can I access toolchains?NoYesN/A
Can I access the platform/config?NoYesN/A
Cached how?Per BUILD filePer targetPer action (content-addressed)
Typical errors?Syntax, bad load(), bad macroMissing providers, missing toolchainCompilation errors, missing inputs