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.
All posts in the "Migrating to Bazel Modules" series
- Migrating to Bazel Modules (a.k.a. Bzlmod) - The Easy Parts
- Migrating to Bazel Modules (a.k.a. Bzlmod) - Repo Names and Runfiles
- Migrating to Bazel Modules (a.k.a. Bzlmod) - Repo Names and rules_pkg
- Migrating to Bazel Modules (a.k.a. Bzlmod) - Repo Names, Macros, and Variables
- Migrating to Bazel Modules (a.k.a. Bzlmod) - Module Extensions
- Migrating to Bazel Modules (a.k.a. Bzlmod) - Fixing and Patching Breakages
- Migrating to Bazel Modules (a.k.a. Bzlmod) - Repo Names, Again…
- Migrating to Bazel Modules (a.k.a. Bzlmod) - Toolchainization
- Migrating to Bazel Modules (a.k.a. Bzlmod) - Maintaining Compatibility, Part 1
- Migrating to Bazel Modules (a.k.a. Bzlmod) - Maintaining Compatibility, Part 2
- Migrating to Bazel Modules (a.k.a. Bzlmod) - Maintaining Compatibility, Part 3
- Migrating to Bazel Modules (a.k.a. Bzlmod) - Maintaining Compatibility, Part 4
Prerequisites¶
As always, please acquaint yourself with the following concepts pertaining to external repositories if you've yet to do so:
To review many key differences between Bzlmod and the legacy WORKSPACE model,
see the comparison table from the "Module Extensions"
post.
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 anybazel_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:
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.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_depsin myrules_scalawork up to eleven as time has gone on. It did occur to me not everyone will want to jump toprotobufv30.2 out of the box, and won't necessarily like having to use asingle_version_overrideto 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:
- bazel-contrib/rules_scala#1726: Establish minimum compatible Bazel, dep versions
- bazel-contrib/rules_scala#1729: Add test_dependency_versions, update test_runner
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 byWORKSPACEfiles.
Here's the WORKSPACE configuration for the rules_scala v7.0.0 Scala,
Scalafmt, and ScalaTest toolchains (without http_archive and setup macro
boilerplate):
And here's the equivalent Bzlmod configuration:
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¶
Module extensions and legacy WORKSPACE files never need access to rule or
aspect implementations. At the same time, 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.
What's more, loading such .bzl files from legacy WORKSPACE files could break
builds using Bazel 8 prereleases up to 8.0.0rc6. This is because Bazel began
removing builtin symbols that're now provided by separate repositories (e.g.,
java_common and JavaInfo now reside in rules_java). Failures from such
files looked something like the following, whereby a .bzl file references
java_common:
The discussion in bazel-contrib/rules_scala#1652 inspired a temporary
workaround to reintroduce Java symbols into legacy WORKSPACE loading for Bazel
8. It works by injecting nonfunctional versions of Java related symbols into the
legacy WORKSPACE environment for backwards compatibility. It landed in Bazel
8.0.0rc7 and in Bazel 9.0.0-pre.20241205.2.
Of course, it's best practice not to rely on backwards compatibility
workarounds. From the perspective of module extensions and legacy WORKSPACE
files, it's best not to rely on files referencing rule or aspect definitions
at all. Properly partitioned .bzl files avoid this specific problem
altogether.
Load formerly builtin symbols from rules packages¶
The other lesson from the previous example is that one should load previously
builtin symbols from external repositories as soon as possible. In the case of
rules_java, the previously builtin Java symbols no longer exist as of Bazel
9.0.0-pre.20250516.1. This means Bazel 9 rolling releases now break
.bzl files that do not load these symbols from rules_java:
The fix for these specific breakages is to add these statements to the .bzl
file:
| Loading the required Java symbols from rules_java | |
|---|---|
This specific fix depends on requiring rules_java v7.5.0 at a minimum. If you can upgrade to at least that version right now, you'll be safe from future Bazel 9 breakages. If you can't upgrade right now, you have some time before it becomes a showstopper. However, it's best to start gradually upgrading your project's dependencies as soon as possible. This will enable you to avoid this problem (and many others) eventually, before it becomes an emergency.
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_depswould reside in a//:deps.bzlfile or similar.nonmodule_depscould reside in the same file or a separate one. -
rules_scala_dependenciesresides in //scala:deps.bzl, andscala_toolchainsresides 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:
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 | |
|---|---|
In MODULE.bazel, we call use_extension with dev_dependency = True to
ensure it doesn't impact user builds:
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!
Updates¶
2025-10-09¶
-
Put the list of all posts in the series into the collapsible All posts in the "Migrating to Bazel modules" series info block.
-
Added a suggestion to review the Module Extensions comparison table to the Prerequisites section.
2025-09-03¶
- Updated the Separate configuration .bzl files from rule and aspect .bzl files with more specific information and guidance. Extracted the new Load formerly builtin symbols from rules packages section from it. Recent work on the upcoming Bzlmod Migration Bootcamp for BazelCon 2025 inspired these updates.