Bazel's old WORKSPACE model had a long run. It was powerful, familiar, and
occasionally the place where every build-system shortcut in the company went to
hide. But the center of gravity has moved. Bazel 8 disabled WORKSPACE by
default, Bazel 9 removed support, and the modern dependency story is
MODULE.bazel plus Bzlmod.
That sounds like a syntax migration. It is not.
Moving from WORKSPACE to Bzlmod changes how your repository declares external
dependencies, how transitive modules are resolved, how generated repositories are
made visible, and how much accidental global state your build can rely on. That
is good for long-term maintainability, but the first migration can feel awkward
if your current WORKSPACE has years of http_archive, language-specific
*_deps() macros, local repository hacks, toolchain registration, and one or
two mysterious comments that say "do not remove this."
This article is the practical migration guide I would want in front of me before touching a serious repo. If you want the release context first, read Bazel 8.0.0: Key Changes and What They Mean for Your Build Pipelines and Bazel 9.1.0: What Changed and How to Think About the Upgrade. If you want the deeper reason this matters, the short version is hermeticity: fewer hidden inputs, fewer "works on my laptop" failures, and a build graph that is easier to reason about. The longer version is in Hermeticity - So Hot Right Now.
Start With an Inventory, Not an Edit
The worst Bzlmod migration strategy is opening WORKSPACE, copying lines into
MODULE.bazel, and hoping the error messages eventually get bored.
Start by classifying what is in WORKSPACE:
rg '^(load|http_archive|git_repository|local_repository|new_local_repository|register_toolchains|register_execution_platforms|bind|.*_deps\()' WORKSPACE WORKSPACE.bazel
You are looking for categories, not just names:
- External Bazel rulesets, such as
rules_cc,rules_java,rules_python,rules_go, orrules_nodejs. - Language package integrations, such as Maven, pip, npm, Go modules, or Cargo.
- Raw archives and Git repositories brought in with
http_archiveorgit_repository. - Local repositories used for development or generated code.
- Toolchains and execution platforms.
- Deprecated
bind()aliases. - Custom repository rules.
- Macros that pull in transitive dependencies behind your back.
That last one is where a lot of migrations get lumpy. In the WORKSPACE era,
it was common to load a ruleset and then call a macro like foo_dependencies()
or foo_register_toolchains(). Sometimes those macros were tidy. Sometimes they
introduced a small city of repositories with names the application repo never
declared directly.
Bzlmod wants a more explicit dependency graph. That is the point.
Add the Smallest Useful MODULE.bazel
A minimal MODULE.bazel starts with your module identity and direct Bazel module
dependencies:
module(
name = "example_app",
version = "0.1.0",
)
bazel_dep(name = "platforms", version = "0.0.11")
bazel_dep(name = "rules_cc", version = "0.1.1")
bazel_dep(name = "rules_python", version = "1.4.1")
The exact versions depend on your repo, so do not treat this snippet as a magic set of current best versions. Treat it as the shape of the file: root module metadata first, then direct module dependencies.
For dependencies available in the
Bazel Central Registry, prefer bazel_dep.
That gives Bazel a module-aware dependency with metadata, compatibility
information, and transitive dependency resolution. This is cleaner than keeping a
long-lived http_archive for a ruleset that already publishes a module.
The mental model is important:
WORKSPACEmostly answered "what repositories should exist?"- Bzlmod starts by answering "what modules does this module depend on?"
- Module extensions bridge the gap when external package managers still need to generate repositories.
That difference is why blindly translating every old repository rule into the new file usually produces a messy result.
Use WORKSPACE.bzlmod as a Migration Crutch
If you are migrating a large repo, you may not be able to move everything in one
pass. Bazel supports WORKSPACE.bzlmod as a transition mechanism. When Bzlmod is
enabled and WORKSPACE.bzlmod exists, Bazel uses it instead of WORKSPACE.
That gives you a useful trick:
- Keep the old
WORKSPACEso non-Bzlmod builds still have a path back. - Add
MODULE.bazel. - Add an initially small
WORKSPACE.bzlmod. - Move dependencies from
WORKSPACE.bzlmodintoMODULE.bazeland module extensions untilWORKSPACE.bzlmodcan disappear.
The official Bazel migration guide recommends this style because it makes the remaining legacy surface visible. I like it because it lowers the emotional temperature. You can migrate a complicated build in controlled slices instead of turning every dependency problem into one giant upgrade branch.
For a fresh Bazel 9-only repo, do not keep this crutch longer than needed. It is a migration tool, not an architectural pattern.
Replace Ruleset Archives With bazel_dep
A common WORKSPACE block looks something like this:
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
http_archive(
name = "rules_python",
sha256 = "...",
strip_prefix = "rules_python-1.4.1",
url = "https://github.com/bazelbuild/rules_python/releases/download/1.4.1/rules_python-1.4.1.tar.gz",
)
In Bzlmod, that usually becomes:
bazel_dep(name = "rules_python", version = "1.4.1")
That is the happy path. Take it whenever it is available.
There are still cases where you need overrides:
bazel_dep(name = "rules_python", version = "1.4.1")
archive_override(
module_name = "rules_python",
urls = ["https://example.com/internal-mirror/rules_python-1.4.1.tar.gz"],
integrity = "sha256-...",
strip_prefix = "rules_python-1.4.1",
)
Use overrides deliberately. They are useful for internal mirrors, emergency patches, and controlled forks. They are less useful as a permanent substitute for understanding why your dependency is not available as a normal Bazel module.
Move Package Manager Integrations to Module Extensions
Some dependencies are not naturally Bazel modules. Maven artifacts, Python
packages, npm packages, and similar ecosystems often need a ruleset-specific
extension that reads declarations in MODULE.bazel and generates repositories.
The rough shape looks like this:
bazel_dep(name = "rules_jvm_external", version = "6.8")
maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")
maven.install(
artifacts = [
"com.google.guava:guava:33.4.8-jre",
"junit:junit:4.13.2",
],
repositories = [
"https://repo1.maven.org/maven2",
],
)
use_repo(maven, "maven")
The important pieces are use_extension() and use_repo().
use_extension() brings the extension into your module so you can provide
extension-specific tags. use_repo() makes the repositories generated by that
extension visible to your module.
That visibility step trips people up. In WORKSPACE, repository names often
felt global. In Bzlmod, repository visibility is stricter. A generated repository
does not help you unless the module that needs it can see it. This is a feature,
but it can make the first migration feel more explicit than the old setup.
When debugging, run:
bazel mod deps
That forces module extension evaluation and is often more useful than waiting for a later build target to stumble into a missing repository.
Treat Toolchains as a First-Class Migration Step
Toolchains are one of the places where old WORKSPACE setups accumulate hidden
behavior. In Bzlmod, register_toolchains() and
register_execution_platforms() belong in MODULE.bazel, not inside a module
extension implementation.
For example:
register_toolchains("@local_config_cc//:all")
register_execution_platforms("//tools/platforms:linux_x86_64")
Do not treat this as housekeeping. Toolchain registration controls what actually runs your builds and tests. If you migrate dependencies but accidentally change toolchain resolution, you can get failures that look unrelated to Bzlmod: different compiler paths, different Python runtimes, different platform constraints, or remote-execution mismatches.
This is where I would be boring and methodical:
- Capture the current toolchain-related flags in
.bazelrc. - Identify toolchains registered from
WORKSPACEmacros. - Move registrations explicitly.
- Run platform-specific builds before declaring victory.
If your repo builds on macOS laptops and Linux CI workers, validate both. The cost of catching toolchain drift early is much lower than chasing subtle cache or compiler differences later.
Replace bind() Before It Replaces Your Afternoon
bind() was deprecated long before Bzlmod, but old repos sometimes still have
targets like this:
bind(
name = "openssl",
actual = "@my_ssl//src:openssl-lib",
)
That creates references through //external:openssl. Bzlmod does not support
bind(), and this is a good opportunity to remove the indirection.
The direct fix is to replace usages:
//external:openssl
with:
@my_ssl//src:openssl-lib
If you still want a stable internal alias, create a normal alias() target:
alias(
name = "openssl",
actual = "@my_ssl//src:openssl-lib",
)
Then depend on //third_party:openssl or whatever package makes sense in your
repo. I prefer that pattern because it keeps the compatibility shim in the main
repo where code search can find it. Build magic that hides in special namespaces
has a way of becoming archaeology.
Keep the Lockfile Under Review
Once Bzlmod is in place, MODULE.bazel.lock becomes part of the operational
surface of your build. It is not just a generated nuisance. It captures resolved
module and extension state so Bazel can keep dependency resolution reproducible.
That has two consequences.
First, check it in unless you have a very specific reason not to. Reproducible build inputs are the whole game here.
Second, review it intelligently. A dependency update PR that changes
MODULE.bazel and MODULE.bazel.lock should make sense as a pair. If the
lockfile changes wildly for a small version bump, that is worth understanding.
For more on the why, see What are the Benefits of a Bzlmod Lockfile?.
A Practical Migration Loop
For a real repo, I would use a loop like this:
bazel clean --expunge
bazel mod deps
bazel build --nobuild //...
bazel test --test_output=errors //...
Then fix the next class of errors:
- Missing repository: add a
bazel_dep,use_repo, override, or temporaryWORKSPACE.bzlmodentry. - Missing load: update BUILD files or macros to load rules from external rulesets explicitly.
- Toolchain mismatch: move registration into
MODULE.bazeland check platform constraints. - Extension not evaluated: add the relevant
use_repo()or inspect withbazel mod deps. - Legacy alias: replace
//externalorbind()usage. - Ruleset incompatibility: upgrade the ruleset, not just Bazel itself.
The --nobuild pass is useful because it separates loading and analysis
problems from actual compilation. Do not skip tests, though. Bzlmod migrations
can affect runtime files, generated code, and toolchains in ways that only show
up when tests execute.
If the repo is large, do not start with //... as your only signal. Pick
representative targets:
bazel build --nobuild //services/api:all
bazel build --nobuild //tools/...
bazel test //libraries/core/...
You want coverage across languages and toolchains, not just a huge failure log.
Common Traps
The first trap is assuming all repository names are globally visible. Bzlmod is
stricter. If a generated repo is not visible where you need it, add the correct
use_repo() or restructure the extension usage.
The second trap is migrating the top-level ruleset but not the ruleset ecosystem.
Bazel 9 plus old rulesets is a great way to meet confusing provider errors. When
you bump Bazel, inspect MODULE.bazel at the same time.
The third trap is keeping too much in WORKSPACE.bzlmod forever. That file is
useful during migration, but it should make you slightly uncomfortable. Every
dependency left there is one more piece of legacy state you have not modeled in
the module system.
The fourth trap is forgetting internal developer workflows. CI might build, but local workflows can still break if developers use local path overrides, custom toolchains, generated repositories, or offline fetch behavior. Test the commands people actually run.
The fifth trap is treating Bzlmod as less flexible than WORKSPACE because the
first migration is more explicit. In practice, the stricter model is what makes
large builds more understandable. The discomfort is real, but so is the payoff.
What Good Looks Like
A good migration does not merely "make Bazel 9 pass." It leaves the repo in a state where an engineer can answer basic questions without spelunking through years of build sediment:
- What are our direct Bazel module dependencies?
- Which package managers are integrated through module extensions?
- Which generated repositories are visible to this module?
- Which toolchains and execution platforms are registered?
- Which overrides are temporary, and why do they exist?
- Can a fresh machine reproduce dependency resolution from checked-in files?
That is the reason to do this work carefully. Bzlmod is not just a compatibility tax for newer Bazel releases. It is a chance to turn external dependency management into something explicit enough to operate.
Final Recommendation
If your repo still depends on WORKSPACE, treat the migration as build-system
maintenance with real engineering value, not as janitorial churn. Start with an
inventory. Move obvious rulesets to bazel_dep. Use module extensions for
package managers. Make toolchain registration explicit. Keep the lockfile under
review. Remove WORKSPACE.bzlmod once it has done its job.
Most importantly, migrate in a way that leaves breadcrumbs for the next person. Bazel builds tend to outlive the engineers who first wired them together. Future you, trying to debug a CI failure at an inconvenient hour, will appreciate every explicit dependency and boring comment you leave behind.
For more build-system and developer productivity notes, keep an eye on slaptijack.com.