Skip to content

Migrating to Bazel Modules (a.k.a. Bzlmod) - Maintaining Compatibility, Part 1

As we're well aware by now, Bzlmod is the new hotness, and WORKSPACE is old and busted and going away in Bazel 9. However, Bazel 8 still supports WORKSPACE, and thus legacy WORKSPACE usage won't completely disappear for some years yet. Despite the Bazel community's efforts to help facilitate Bzlmod migrations (including this blog series), some projects may remain unable to migrate sooner than later.

What's more, publishing your repository for use by other projects raises the challenge of supporting a range of older and newer versions of its dependencies. Your repository should work with the newest versions of Bazel, rules_java, protobuf, and other dependencies in order to stay current. However, not every project that could benefit from the latest version of your repository will want to upgrade these other dependencies right away.

This post describes how to design a repository to remain compatible with both WORKSPACE and Bzlmod, and with newer and older dependency versions. Just as importantly, in the next two posts, we'll discuss testing approaches to help ensure that this remains the case.

This article is part of the series "Migrating to Bazel Modules (a.k.a. Bzlmod)":

Prerequisites

As always, please acquaint yourself with the following concepts pertaining to external repositories if you've yet to do so:

Library vs. root modules

The advice in this post applies primarily to "library" modules that are dependencies of other modules—especially open source Bazel Central Registry modules.

"Root" modules that are never dependencies of other modules need not maintain compatibility with WORKSPACE or older dependency versions. For these modules, using Bzlmod exclusively and up to date dependency versions is generally the best approach.

Note that we're overloading the term "root" in the Bazel context. When developing a "library" module, it will be the root module of its own Bazel build.

Compatibility with WORKSPACE and older dependencies eases Bzlmod adoption

Maintaining compatibility with WORKSPACE and with a broad range of dependency versions (including Bazel versions) helps establish a workable path towards Bzlmod adoption. It enables projects to adopt newer, Bzlmod compatible versions of your repository without forcing disruptive changes—like unwanted dependency updates or an immediate Bzlmod migration. When they're eventually ready to migrate to Bzlmod, or to update other dependencies, your project will have already made it possible.

Explicit version ranges are especially helpful to WORKSPACE users.

Maintaining and documenting support for an explicit range of dependency versions can prove especially helpful to WORKSPACE users. Whereas Bzlmod can automatically detect and clearly report version incompatibilities, WORKSPACE conflicts are generally more opaque. Identifying which dependency versions are compatible with your repository may provide insight into otherwise difficult to diagnose WORKSPACE dependency problems.

Minimal Version Selection principles

Simon Stewart inspired this blog topic in a Bazel Slack chat on 2025-04-02, echoing the principles behind Bzlmod's Minimal Version Selection mechanism:

As someone who maintains rulesets, it’s dawning on me that I should probably be listing the lowest possible versions of my ruleset’s bazel_deps so that I don’t force projects who use them to update unless they want to. That is, it feels that the root module should be the one that specifies the highest version of any bazel_dep, and non-root modules should be specifying the lowest version that they work with.

The reasoning is that this means that the root module can then pick more recent versions without being forced to take an update it doesn’t want.

How are other people thinking about this?

Chuck Grindel noted that there's no official consensus, but that in bazel-contrib/SIG-rules-authors#82: Renovate Preset for Bazel Rulesets he proposed:

  1. Do not update dependencies defined in the root MODULE.bazel. The idea is that a ruleset should strive to require the lowest viable version of an external dependency so that clients can dictate the version of a dependency to use.

  2. Find and update dependencies defined in child workspaces (i.e., in a repository subdirectory). While a ruleset should require the lowest viable version of an external dependency, ensuring that the ruleset works with the most recent version of an external dependency is desirable. Hence, example and test workspaces should be updated to the latest version.

While I'd carefully ensured both WORKSPACE and Bzlmod compatibility for rules_scala from the beginning, I confessed that:

I haven't thought much about this yet, but I have been cranking the bazel_deps in my rules_scala work up to eleven as time has gone on. It did occur to me not everyone will want to jump to protobuf v30.2 out of the box, and won't necessarily like having to use a single_version_override to avoid it.

Guess I'll have to prep a branch to roll the volume down to seven after bazelbuild/rules_scala#1722 lands.

Indeed, I took Chuck's advice to heart, and eventually landed:

The rules_scala README now explicitly documents its minimum dependency requirements as well.

The advice below reflects techniques from those pull requests, as well as techniques I'd applied to maintain WORKSPACE compatibility while implementing Bzlmod support.

Dependency challenges aren't new, but are amplified under Bzlmod

Arguably, maintaining compatibility with a range of dependency versions has been a longstanding problem throughout the history of software development. However, Bzlmod is less forgiving than WORKSPACE in many ways, and adopting it may require significant effort (hence, again, this blog series). Though Bzlmod's strictness is a feature yielding many benefits, narrow dependency requirements can further inhibit projects currently working under WORKSPACE from switching to Bzlmod.

Elements of compatible design

Maintaining compatibility begins with proper design, both of the public interface and the internal structure. Correcting such issues may require fundamental changes that go beyond all the other techniques covered throughout this Bzlmod blog series. This post offers a few suggestions for achieving a broadly compatible design.

Make the WORKSPACE and Bzlmod APIs as similar as possible

This is the primary principle driving continued WORKSPACE support. From a project's perspective, the WORKSPACE and Bzlmod models are merely two different ways of expressing the same essential configuration information. All the dependency setup macros and ordering sensitivity of the WORKSPACE model notwithstanding, the same clusters of actual configuration options remain the same. With careful design, the APIs supporting each model can prove only superficially different, despite the radically different dependency resolution models.

In the Toolchainization post, I mentioned that rules_scala 7.0.0 introduced a new WORKSPACE API that was very similar to the new Bzlmod API. I designed both APIs to ensure that the Bzlmod API is essentially a thin layer sitting on top of the WORKSPACE API. Specifically:

  • The Bzlmod module extension layer merely collects information from its tag classes instantiated throughout the module graph.

  • It then applies this information as arguments to the same scala_toolchains() macro used directly by WORKSPACE files.

Here's the WORKSPACE configuration for the rules_scala v7.0.0 Scala, Scalafmt, and ScalaTest toolchains (without http_archive and setup macro boilerplate):

rules_scala v7.0.0 WORKSPACE configuration example
# WORKSPACE

# http_archive(), rules_scala_dependencies(), dependency setup macros...

load("@rules_scala//:scala_config.bzl", "scala_config")

scala_config(scala_version = "2.13.16")

load(
    "@rules_scala//scala:toolchains.bzl",
    "scala_register_toolchains",
    "scala_toolchains",
)

scala_toolchains(
    # scala = True is the default under WORKSPACE
    scalafmt = True,
    scalatest = True,
)

scala_register_toolchains()

And here's the equivalent Bzlmod configuration:

rules_scala v7.0.0 Bzlmod configuration example
# MODULE.bazel

# Instead of http_archive(), rules_scala_dependencies(), dependency macros
bazel_dep(name = "rules_scala", version = "7.0.0")

scala_config = use_extension(
    "@rules_scala//scala/extensions:config.bzl",
    "scala_config",
)
scala_config.settings(scala_version = "2.13.16")

scala_deps = use_extension(
    "@rules_scala//scala/extensions:deps.bzl",
    "scala_deps",
)
scala_deps.scala()
scala_deps.scalafmt()
scala_deps.scalatest()

# rules_scala automatically calls register_toolchains() for these toolchains.

The new v7.0.0 API dropped a lot of previous WORKSPACE macros, but all the previous functionality remained. The new scala_toolchains macro consolidated options previously spread across dozens of previous macros imported from several different files.

The existing test suite, while requiring some modifications to handle slightly differing log outputs, proved essential to ensuring compatibility between WORKSPACE and Bzlmod builds. Then when others wondered whether it was worth launching Bzlmod separately from WORKSPACE updates, I could confidently assert that it was a moot point. No one who remained on WORKSPACE would have any contact with the new Bzlmod layer, which already relied upon the same implementation.

However, such users would have to update to the new WORKSPACE API, which closely resembles the Bzlmod API. This meant we could force WORKSPACE users to take material steps towards an eventual Bzlmod migration, but without immediately forcing a complete migration. At the same time, the new API shrinks WORKSPACE configuration significantly, and basing Bzlmod functionality on the same implementation reduces our maintenance burden.

scala_toolchains trades separation of concerns for usability

One may wonder if consolidating previously disjoint implementations into a single scala_toolchains macro actually degraded the design by mixing previously separate concerns. Each of the previously loaded files could've become its own module extension. However, there's something to be said for reducing the API surface when it improves usability without noticeably harming performance. It makes the resulting MODULE.bazel configuration more compact and understandable, and thus more maintainable and less error prone.

Also, some of the previous WORKSPACE toolchain dependency macros weren't as disjoint as they seemed, as they'd instantiate the same Maven artifact repositories. WORKSPACE allows this, while Bzlmod expressly forbids it. scala_toolchains ensures the creation of only one instance of each repository required by multiple toolchains, under both WORKSPACE and Bzlmod. Having each toolchain in a separate module extension would've worked, but in addition to maintaining a broader API, it would've unnecessarily duplicated several repositories.

scala_toolchains also dynamically generates the @rules_scala_toolchains repo containing all configured toolchains. The "@rules_scala_toolchains//...:all" target specifier replaces separate specifiers for each toolchain in register_toolchains calls. This is why scala_register_toolchains replaced all the WORKSPACE registration macros, and why the rules_scala module can make this call automatically. Having separate toolchain repos would've complicated this automatic registration under Bzlmod, and would've required keeping the separate registration macros under WORKSPACE.

See the Toolchainization blog post for much more detail.

Separate configuration .bzl files from rule and aspect .bzl files

WORKSPACE and module extensions never need access to rule or aspect implementations, and BUILD files never need access to repository_rules or module extensions. Therefore it's good practice to keep .bzl files used by WORKSPACE or module extensions separate from those used by BUILD files.

Future Bazel versions may break .bzl files loaded by WORKSPACE and module extensions that directly or transitively load files containing rule or aspect implementations. Older Bazel versions provided many symbols required by such implementations, which are now provided by separate repositories (e.g., JavaInfo now resides in rules_java). Newer Bazel versions inject nonfunctional versions of these symbols into the WORKSPACE environment for backwards compatibility.

Of course, it's best practice not to rely on backwards compatibility workarounds. From the perspective of WORKSPACE and module extension files, it's best not to rely on files referencing rule or aspect definitions at all. When Bazel drops the compatibility workarounds one day, such properly partitioned .bzl files will continue to work. This means projects using your module won't be held back from updating Bazel one day because of your module's intermingled .bzl files.

Separate Bazel module compatible dependencies from non-module dependencies

You may need to define (at least) two macros: one to instantiate repositories also available as Bazel modules, and one for repositories that aren't. WORKSPACE will invoke both macros. Under Bzlmod, you'll import the Bazel module dependencies using bazel_dep, and wrap the macro instantiating non-module dependencies in a module extension.

Here's a summary of how to instantiate the two kinds of dependencies under WORKSPACE and Bzlmod. module_deps and nonmodule_deps are placeholders for whatever macro names you choose for your project.

Mode Module dependencies Non-module dependencies
WORKSPACE Invoke module_deps in WORKSPACE Invoke nonmodule_deps in WORKSPACE
Bzlmod Invoke bazel_dep in MODULE.bazel Invoke nonmodule_deps in a module extension

Though the dependency version duplication between module_deps and bazel_dep is unavoidable, nonmodule_deps keeps non-module versions synchronized between WORKSPACE and Bzlmod.

As a concrete example, here's how rules_scala v7.0.0 instantiates its dependencies:

Mode Module dependencies Non-module dependencies
WORKSPACE Invokes rules_scala_dependencies in WORKSPACE to instantiate bazel_skylib, rules_java, protobuf, et. al. Invokes scala_toolchains in WORKSPACE to instantiate the Maven dependency repos used by the builtin toolchains
Bzlmod Invokes bazel_dep in MODULE.bazel to instantiate bazel_skylib, et. al. Invokes scala_toolchains via the scala_deps module extension

Create deps.bzl, latest_deps.bzl, and dev_deps.bzl for WORKSPACE

Providing macros to instantiate your repository's required dependencies, in a file often named something like deps.bzl, is a common WORKSPACE convention. In the examples from the previous section:

  • module_deps would reside in a //:deps.bzl file or similar. nonmodule_deps could reside in the same file or a separate one.

  • rules_scala_dependencies resides in //scala:deps.bzl, and scala_toolchains resides in //scala:toolchains.bzl.

Nothing to add here, other than to reiterate the point that it should contain the minimum supported version of each required dependency. If users wish to use a later version, instruct them to import those versions before invoking your deps.bzl macros.

However, you should also create a latest_deps.bzl containing the maximum version of each dependency supported by your project, for development use only. load this file into your project's WORKSPACE file instead of deps.bzl to ensure your project remains compatible with current dependency versions during development. (Remember, a future post will cover writing a backwards compatibility test suite for older dependency versions.)

You can also create dev_deps.bzl with a macro instantiating all of your development only dependencies that are not also published as Bazel modules. These dependencies can use whichever version you prefer. Although leaving development dependencies in your WORKSPACE file won't affect users, you can wrap a dev_deps macro in a module extension. As with the nonmodule_deps macro described in the previous section, this keeps dependency information synchronized between WORKSPACE and Bzlmod builds.

latest_deps.bzl and dev_deps.bzl are for development use only.

latest_deps.bzl is the WORKSPACE analog for the single_version_override directives described later in this post. dev_deps.bzl is the WORKSPACE analog for Bzlmod's dev_dependency = True parameter for bazel_dep, use_extension, and register_toolchains.

Specify the minimum supported dependency versions with bazel_dep

As we all know, Bzlmod automatically resolves dependency versions across all Bazel modules. Therefore, you should specify the minimum supported version of each dependency directly in each bazel_dep. This allows Bazel to select the latest compatible versions from across the entire dependency graph, which the main module can easily (and deterministically) override.

Note that bazel_dep instances marked as dev_dependency = True are only included when building the project itself as the main module. Consumers of the project shouldn't be affected by them at all. As with dev_deps.bzl, this means you can set these dependencies to any version you want: oldest, newest, or in between.

Module resolution applies only to bazel_dep repositories

More accurately, Bazel only resolves the versions of other Bazel modules; http_archive repositories and the like aren't included in the version resolution process. At the same time, http_archive repositories and the like are encapsulated within the module extensions that instantiate them. This also means that each module can use its own version of such repos without interference from any others. Under WORKSPACE, there would be only one version of each repo of the same name, determined by the order of WORKSPACE statements.

Specify the latest dependency versions with single_version_override

As with latest_deps.bzl, to ensure compatibility with the latest dependency versions, use single_version_override in your MODULE.bazel file. Like bazel_dep instances marked with dev_dependency = True, these overrides only take effect when the project itself is the main module.

For example, here's how the combination of bazel_dep and single_version_override looks in rules_scala:

bazel_dep and single_version_override pairs in rules_scala v7.0.0
bazel_dep(name = "bazel_skylib", version = "1.6.0")
single_version_override(
    module_name = "bazel_skylib",
    version = "1.7.1",
)

bazel_dep(name = "platforms", version = "0.0.9")
single_version_override(
    module_name = "platforms",
    version = "1.0.0",
)

bazel_dep(name = "rules_java", version = "7.6.0")
single_version_override(
    module_name = "rules_java",
    version = "8.12.0",
)

bazel_dep(name = "rules_proto", version = "6.0.0")
single_version_override(
    module_name = "rules_proto",
    version = "7.1.0",
)

bazel_dep(
    name = "protobuf",
    version = "28.2",
    repo_name = "com_google_protobuf",
)

# Temporarily required for `protoc` toolchainization until resolution of
# protocolbuffers/protobuf#19679.
single_version_override(
    module_name = "protobuf",
    patch_strip = 1,
    patches = ["//protoc:0001-protobuf-19679-rm-protoc-dep.patch"],
    version = "31.1",
)

A nice feature of this arrangement is having working code express the minimum and maximum supported versions of each dependency right next to each other. (Note that the Temporarily required comment actually applies to the patches and patch_strip properties, not the entire override in this case. In the nested testing modules, which I'll describe in the next post, the entire override is temporary.)

Encapsulate non-module development dependencies in a dev_dependency extension

As mentioned earlier, wrapping non-module development dependencies in a macro in a dev_deps.bzl file makes them convenient to encapsulate in a module extension. In fact, scala/private/extensions/dev_deps.bzl in rules_scala v7.0.0 includes both the WORKSPACE macro and the module extension.

Here's how it's used in WORKSPACE:

Using dev_deps.bzl in WORKSPACE
1
2
3
4
5
# WORKSPACE

load("//scala/private/extensions:dev_deps.bzl", "dev_deps_repositories")

dev_deps_repositories()

In MODULE.bazel, we call use_extension with dev_dependency = True to ensure it doesn't impact user builds:

Using dev_deps.bzl in MODULE.bzl
# MODULE.bazel

internal_dev_deps = use_extension(
    "//scala/private/extensions:dev_deps.bzl",
    "dev_deps",
    dev_dependency = True,
)

# See //scala/private:extensions/dev_deps.bzl for notes on some of these repos.
use_repo(
    internal_dev_deps,
    "com_github_bazelbuild_buildtools",
    "com_github_jnr_jffi_native",
    "com_google_guava_guava_21_0",
    "com_google_guava_guava_21_0_with_file",
    "com_twitter__scalding_date",
    "org_apache_commons_commons_lang_3_5",
    "org_apache_commons_commons_lang_3_5_without_file",
    "org_springframework_spring_core",
    "org_springframework_spring_tx",
    "org_typelevel__cats_core",
    "org_typelevel_kind_projector",
)

Keep the WORKSPACE and Bzlmod dependency versions the same

In case it's not immediately obvious, the minimum and maximum dependency versions for both WORKSPACE and Bzlmod builds should be identical. This makes it easier for both users and maintainers to reason about, and helps avoid unexpected version changes when switching from WORKSPACE to Bzlmod. It might not avoid all unexpected dependency version changes, but it should minimize them, at least.

Create a new release when minimum dependency versions change

A change to the minimum supported version of any dependency necessitates a new release. This is because your module must have changed in a way to require a new minimum version of that dependency. Changes to the maximum supported version of a dependency only require a new release if the update required changes to your module as well.

Per the semantics of semantic versioning:

  • When an updated dependency doesn't produce a user visible change in your module's API, you should be able to make a minor version release. This indicates that, as long as all dependencies are within their supported ranges, existing builds should continue to work after an upgrade without further action.

  • If updating a dependency would force users to change their code after updating to your module's new version, that's cause for a major version release. This indicates that the new version of your module contains at least one known change that could potentially break existing builds.

Major version releases should include a compatibility_level bump as well. Since major version and compatibility_level increases can prove disruptive, they should be relatively few and far between.

Conclusion

Requiring users to switch from WORKSPACE to Bzlmod immediately, or to upgrade multiple dependencies in addition to your own, disincentivizes progress. They may (rightly) fear that upgrading to your latest release will unleash chaos, motivating them to stay put and remain stuck on WORKSPACE.

While we can't support all older dependency versions indefinitely, we can achieve a balance that helps minimize the friction involved in pulling the community forward. We've seen that we can keep WORKSPACE builds intact, gently encourage movement towards Bzlmod adoption, and accommodate many dependency versions at the same time. When your users eventually decide to migrate to Bzlmod, or to update Bazel or other dependencies, your project will have already helped prepare them. What's more, it's possible to minimize the maintenance burden of supporting both WORKSPACE and Bzlmod by sharing a core implementation and aligning their APIs.

Of course, it's important to actually test that your project maintains this flexibility, instead of making unverified assumptions. The next two posts will summarize testing approaches to validate compatibility with WORKSPACE builds, Bzlmod builds, and the vast majority of existing users' dependencies:

  • The next post will summarize elements of Bazel tests that allow for flexibly switching between build modes and Bazel versions, while using newer dependency versions.

  • The post after that will describe how to write a dependency version compatibility smoke test, and strategies for running tests locally and in continuous integration.

As always, I'm open to questions, suggestions, corrections, and updates relating to this series of Bzlmodification posts. It's easiest to find me lurking in the #bzlmod channel of the Bazel Slack workspace. I'd love to hear how your own Bzlmod migration is going—especially if these blog posts have helped!