Migrating from WORKSPACE to Bzlmod Without Making Your Build Weird

Posted on in Programming

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, or rules_nodejs.
  • Language package integrations, such as Maven, pip, npm, Go modules, or Cargo.
  • Raw archives and Git repositories brought in with http_archive or git_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:

  • WORKSPACE mostly 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:

  1. Keep the old WORKSPACE so non-Bzlmod builds still have a path back.
  2. Add MODULE.bazel.
  3. Add an initially small WORKSPACE.bzlmod.
  4. Move dependencies from WORKSPACE.bzlmod into MODULE.bazel and module extensions until WORKSPACE.bzlmod can 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 WORKSPACE macros.
  • 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 temporary WORKSPACE.bzlmod entry.
  • Missing load: update BUILD files or macros to load rules from external rulesets explicitly.
  • Toolchain mismatch: move registration into MODULE.bazel and check platform constraints.
  • Extension not evaluated: add the relevant use_repo() or inspect with bazel mod deps.
  • Legacy alias: replace //external or bind() 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.

Slaptijack's Koding Kraken