How Bazel discovers and fetches external code — rule sets, toolchains, and third-party libraries — before your build even starts.
The Fetch Phase
Before the three main phases run, there’s a preliminary step: fetching. This is when Bazel downloads external dependencies and makes them available as repositories (directories with their own BUILD files). Fetching is handled by repository rules — special Starlark rules that can do things forbidden everywhere else: download files from the internet, run external commands, inspect the host system.
Once fetched, an external repository is just a directory of files. @rules_rust//rust:defs.bzl means “the file rust/defs.bzl inside the rules_rust repository.” The @ prefix identifies an external repo.
WORKSPACE (Legacy)
The WORKSPACE file at the repo root declares external dependencies via sequential http_archive calls and load() chains:
http_archive(name = "rules_rust", sha256 = "...", urls = [...])
load("@rules_rust//rust:repositories.bzl", "rules_rust_dependencies", "rust_register_toolchains")
rules_rust_dependencies()
rust_register_toolchains(edition = "2021", versions = ["1.75.0"])The problems are fundamental:
- Order-dependent. Each
load()depends on a previoushttp_archive()having run. Reorder lines and things break. - No version resolution. If
rules_rustneedsplatforms0.0.8 andrules_pythonneedsplatforms0.0.9, you get whichever was declared first. No “pick the highest compatible version” — just silent conflict. - Diamond dependencies. Two deps depending on different versions of the same library produces undefined behavior.
MODULE.bazel (Bzlmod) — The Modern Approach
Default since Bazel 7. Uses MODULE.bazel at the workspace root:
module(name = "my_project", version = "0.0.1")
# Declare dependencies — like a package manager
bazel_dep(name = "rules_rust", version = "0.40.0")
bazel_dep(name = "rules_python", version = "0.31.0")
bazel_dep(name = "platforms", version = "0.0.9")
# Configure Rust toolchain via a "module extension"
rust = use_extension("@rules_rust//rust:extensions.bzl", "rust")
rust.toolchain(edition = "2021", versions = ["1.75.0"])
use_repo(rust, "rust_toolchains")
register_toolchains("@rust_toolchains//:all")
# Configure Python toolchain
python = use_extension("@rules_python//python/extensions:python.bzl", "python")
python.toolchain(python_version = "3.11", is_default = True)
use_repo(python, "python_3_11")Why it’s better:
- Version resolution. Bzlmod uses MVS (Minimum Version Selection). If two modules need different versions of
platforms, it picks the highest compatible one. - Order-independent.
bazel_dep()calls don’t depend on each other. - Lazy fetching. Dependencies are fetched on demand, cached by content hash.
- Module extensions replace WORKSPACE’s
load()chains with a more structured mechanism.
Tip
For new projects, use Bzlmod exclusively. Both systems can coexist during migration.
crate_universe: Bridging Cargo and Bazel
Rust uses Cargo (Cargo.toml / Cargo.lock) for dependency management. Bazel doesn’t know about Cargo. crate_universe bridges the gap: it reads your Cargo files, downloads crate sources, and generates BUILD.bazel files for each crate so Bazel can build them.
How it works
You provide a Cargo.toml:
[package]
name = "my_module"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
pyo3 = { version = "0.20", features = ["extension-module"] }You configure crate_universe in MODULE.bazel:
crate = use_extension("@rules_rust//crate_universe:extensions.bzl", "crate")
crate.from_cargo(
name = "crate_index",
cargo_lockfile = "//:Cargo.lock",
manifests = ["//rust/my_module:Cargo.toml"],
)
use_repo(crate, "crate_index")During fetching, crate_universe resolves the dependency tree, downloads crate sources, and generates a BUILD file per crate. Each generated file has the right deps, crate_features, edition, proc_macro_deps, etc.
You use the crates as normal Bazel deps:
rust_shared_library(
name = "my_module_rs",
deps = ["@crate_index//:pyo3"],
)Alternative: declaring deps without Cargo.toml
You can skip Cargo.toml entirely and declare Rust deps directly:
crate.spec(name = "crate_index", package = "pyo3", version = "0.20",
features = ["extension-module"])
crate.from_specs(name = "crate_index")The extension-module feature
Warning
For PyO3, the
extension-moduleCargo feature is critical. On Linux, Python extension modules are loaded into the interpreter process viadlopen(). Python symbols come from the interpreter itself, not fromlibpython.so. If your extension also linkslibpython.so, you get duplicate symbols and crashes. Theextension-modulefeature tells PyO3 to skip linkinglibpython.
Always verify this feature is present when reviewing PyO3 builds.
Caching and re-fetching
The @crate_index repository is cached and only re-fetched when Cargo.toml, Cargo.lock, or the crate_universe version changes. Pin Cargo.lock in version control for CI reproducibility.