Bzlmod is easy to underestimate because the first visible artifact looks small: a MODULE.bazel file replaces a lot of old WORKSPACE dependency setup. That is the trap. The migration is not a tidy syntax change. It changes how a Bazel repository declares direct dependencies, how transitive module versions are selected, how generated repositories become visible, how registries participate in trust, and how much accidental global state a build is allowed to rely on.

The timing makes the issue practical rather than theoretical. Bazel's current migration guide frames Bzlmod as the path away from the legacy WORKSPACE system, notes that WORKSPACE was disabled by default in Bazel 8, and describes migration as necessary for Bazel 9-era builds.[1] That makes the wrong adoption plan tempting: turn on the flag, run a broad target, patch whatever CI reports, and call the result done. The safer plan is to treat MODULE.bazel as a new dependency-graph contract.

The cover image comes from BazelCon 2024, held at the Computer History Museum in Mountain View. Google described that event as the first BazelCon not solely organized by Google, with the Linux Foundation and multiple build-tooling sponsors involved; the Bazel blog recap put attendance at 332 people.[8][9] That community context matters. Bzlmod cannot succeed as one repo's local cleanup if core rulesets, the Bazel Central Registry, language-package integrations, and enterprise build owners do not all meet at the same boundary.

Start by auditing what WORKSPACE was hiding

The first migration step is not editing. It is inventory. A mature WORKSPACE file often contains direct third-party archives, custom repository rules, language-specific *_deps() macros, generated toolchain repositories, local repository shortcuts, patch files, mirrors, and comments that preserve institutional memory. In the old model, those statements also tended to create a broad global namespace where a dependency could be visible because some macro happened to run early enough.

Bazel's migration guide recommends using the Bzlmod migration tool first, then resolving the remaining errors manually.[1] The tool's own documentation describes the shape of that help: it analyzes WORKSPACE, uses resolved dependency information, identifies direct dependencies, introduces them to MODULE.bazel, and then iteratively builds targets under Bzlmod to expose what is still missing.[2] That is useful, but it should not be mistaken for a full migration strategy. It can propose a first graph; it cannot decide which old shortcuts deserve to survive.

For a serious repo, pick a representative target set before touching the root. Include production binaries, test-only dependencies, code generators, toolchains, generated clients, platform-specific targets, and whatever has historically broken clean builds. Then write down the external repositories those targets use, which ones are direct product choices, which ones are transitive rule implementation details, and which ones exist only because a legacy macro made them convenient. Bzlmod rewards that distinction.

Prefer module declarations over private fetch scripts

The cleanest Bzlmod case is a direct module dependency declared with bazel_dep(). EngFlow's migration series captures the practical benefit: many http_archive() declarations can become much shorter bazel_dep() lines when the dependency exists in the Bazel Central Registry, while still requiring the project to read the module's own documentation for any extra configuration.[6] That is the easy win worth taking early.

The registry model is the more important win. Bazel's registry documentation describes the Bazel Central Registry as an index registry backed by the bazelbuild/bazel-central-registry GitHub repository, with a browsable frontend and contribution process. It also documents the repeatable --registry flag, registry precedence, and the need to add the central registry back explicitly if a custom registry list replaces the default.[4] For enterprises, that means Bzlmod migration is also a procurement decision: do you trust the public BCR directly, mirror it, fork it, or put internal modules in an internal index?

The adoption rule is conservative. Use bazel_dep() where a real module exists and the BCR metadata is good enough. Use a custom or mirrored registry when network control, availability, review policy, or archive provenance requires it. Use use_repo_rule() for leftover repository rules only when there is no better module path yet.[6] If every dependency is carried forward as a private fetch script, the repo may technically run under Bzlmod while keeping the old operational shape.

That distinction matters for future maintenance. A module declaration can participate in version selection and graph inspection. A private repository rule can still work, but it is closer to a local exception. Exceptions are sometimes necessary. They should be named, reviewed, and retired deliberately rather than hidden inside a migration commit.

Treat module extensions as policy code

Module extensions are where many real migrations become awkward and interesting. The official extension docs show the basic pattern: add a bazel_dep() on the module that hosts an extension, call use_extension() to bring the extension into scope, configure it through tag-like dot syntax, and call use_repo() to make generated repositories visible to the current module.[3] The same page notes that extensions are evaluated lazily and that bazel mod deps can force evaluation while testing.[3]

That laziness is not trivia. It means a migration can appear quiet until a target actually references a generated repository. A repo owner should test the extension paths that production and CI really use, not only the root graph. It also means build owners need to know which repositories are part of an extension's public API. If an extension promises a generated repo named maven, use_repo(maven, "maven") is a dependency boundary, not just a line of setup code.[3]

EngFlow's module-extension writeup is useful because it names the behavioral shift from WORKSPACE: MODULE.bazel does not allow ordinary load() statements, module extensions can carry tag schemas, and repos generated inside extensions sit in namespaces that must be made visible explicitly.[7] That removes a class of global-state surprises, but it also forces teams to redesign old macros that assumed they could load constants, call repository rules, and register visible repos in one loose script.

The migration smell is a giant extension file that recreates the old WORKSPACE with different spelling. Some of that may be unavoidable for platform archives, language-package lockfiles, or toolchain repositories. Still, the extension should have a clear API: which tags users set, which generated repos callers may use_repo, which underlying repository names are internal, and when the root module is allowed to use override_repo() or inject_repo() for a vendor or patch lane.[3]

Make the lockfile an operations boundary

MODULE.bazel.lock is not decoration. Bazel's lockfile docs describe it as the file generated under the workspace root after module resolution and extension evaluation, storing the resolution result so builds can be more reproducible and so Bazel can skip unchanged resolution work.[5] The same docs document --lockfile_mode, including update behavior, refresh behavior, error behavior for CI-like enforcement, and turning lockfile handling off.[5]

That gives teams a concrete rollout choice. Early in a migration, the lockfile may be noisy because many dependencies and extension results are changing at once. EngFlow explicitly warns that lockfile diffs can be large during migration, especially when different developers use different Bazel or tooling versions, and suggests teams may temporarily defer committing the lockfile only if they understand the tradeoff.[6] The endpoint should still be clear: a production build needs a lockfile policy, not an argument every time dependency metadata changes.

A practical rule is to let developers update locally during the migration, then move CI toward stricter behavior once the module graph stabilizes. At that point, MODULE.bazel, MODULE.bazel.lock, registry configuration, and the pinned Bazel version should move together in review. Dependency updates become normal changes to inspect instead of side effects discovered after a build cache miss.

A migration path that does not rely on luck

The safest adoption plan has five gates. First, pin the Bazel version used by CI and local development, then run the migration tool against a target set that includes the ugly parts of the repo, not only the happy path.[2] Second, replace direct, registry-backed dependencies with bazel_dep() and record which old http_archive() or repository-rule declarations remain because no module path exists yet.[4][6]

Third, isolate module extensions by responsibility. Language package resolution, toolchain setup, vendored platform archives, and generated repositories should not all share one anonymous extension because it was convenient during the port. Extension identity can become part of a public API, and moving it later can break users.[3][7] Fourth, decide how registries are selected. A team that needs an internal mirror should encode --registry in .bazelrc, document precedence, and keep the BCR-addback behavior explicit.[4]

Fifth, treat the legacy WORKSPACE path as a temporary compatibility lane with an exit date. Some projects that are themselves dependencies may need dual support for non-Bzlmod consumers during a transition.[6] Application repos with no such obligation should avoid building two dependency systems indefinitely. Dual paths double review work and make it unclear which graph is the source of truth.

The main failure modes are predictable. Custom macros may depend on load() behavior that belongs in an extension file now. Toolchain registration may assume globally visible repositories. Test-only dependencies may appear only when a narrow target is built. Local repository overrides may leak user-specific paths into shared state. Lockfile churn may hide real dependency changes in noise. Registry decisions may be postponed until the first network or provenance incident. None of those are reasons to avoid Bzlmod. They are reasons to run the migration like infrastructure work.

The best reading is blunt: Bzlmod makes Bazel dependency management more explicit, but explicit systems are less forgiving of folklore. Small repositories can often lift and shift quickly. Large monorepos and rule authors need a migration plan that names module declarations, extension APIs, registry trust, lockfile policy, and the last day WORKSPACE remains authoritative. The win is not that MODULE.bazel is shorter. The win is that the dependency graph becomes inspectable enough to operate.

Sources

  1. Bazel Documentation, "Bzlmod Migration Guide" - WORKSPACE retirement context, migration rationale, and WORKSPACE-versus-Bzlmod guidance.
  2. Bazel Documentation, "Bzlmod Migration Tool" - helper-tool workflow for analyzing WORKSPACE, identifying direct dependencies, translating to MODULE.bazel, and iterating on build errors.
  3. Bazel Documentation, "Module extensions" - use_extension, use_repo, lazy evaluation, repository visibility, extension identity, and override/injection behavior.
  4. Bazel Documentation, "Bazel registries" - Bazel Central Registry structure, --registry selection, registry precedence, and BCR contribution/CI expectations.
  5. Bazel Documentation, "Bazel Lockfile" - MODULE.bazel.lock generation, lockfile modes, reproducibility, and resolution-skipping behavior.
  6. Mike Bland, "Migrating to Bazel Modules (a.k.a. Bzlmod) - The Easy Parts." EngFlow Blog, June 27, 2024 - independent migration notes on bazel_dep(), BCR use, use_repo_rule(), dual support, and lockfile noise.
  7. Mike Bland, "Migrating to Bazel Modules (a.k.a. Bzlmod) - Module Extensions." EngFlow Blog, January 16, 2025 - independent explanation of extension constraints, load() differences, namespaces, and modularizing extension code.
  8. Google Open Source Blog, "BazelCon 2024: A celebration of community and the launch of Bazel 8," December 2024 - conference context and source page for the article's real BazelCon audience photograph.
  9. Bazel Blog, "BazelCon 2024 Recap: Recordings and Birds of a Feather Session Notes," November 19, 2024 - attendance count and session-recording context for BazelCon 2024.