The implementation function is where a module extension stops being a schema and becomes dependency logic. module_extension() points at one Starlark function that receives module_ctx. That function reads the tags collected from the resolved module graph, decides which external repositories should exist, usually creates those repositories by calling repository rules, and may return metadata that helps Bazel maintain the caller's use_repo() declarations.14.1.7 Starlark Callback Contexts & Types separates this callback's nominal type, phase, literal return value, and repository-definition effects.
This is module-extension evaluation, not target analysis. During an ordinary build, Bazel typically evaluates an extension when loading needs one of the repositories that the extension defines. A package that references such a repository can therefore trigger the evaluation. Commands such as bazel mod deps can also evaluate extensions directly.
The implementation receives module_ctx and tags, and it can call APIs that define repositories. It does not receive configured Target objects, return target providers, or register build actions. It may declare repositories and observe the host or network before Bazel analyzes a BUILD target that depends on one of those repositories. A conflict between extension tags therefore fails before target analysis.
The Shape Of The Function
A module extension declaration is a global .bzl symbol. Its implementation parameter must be a function that takes one module_ctx argument, and Bazel calls that function to determine the set of repositories made available by the extension.2
Suppose you run Bazel in the app project. That project is the root module for this build. It depends on a module named helper. Both use the same archives extension from a third module, artifact_rules. Being the root means being the project where Bazel is invoked, not being the author of the extension.
Both app and helper request an artifact named schema, with the same URL and checksum. Only helper requests a second artifact, helper_data. The extension must declare both repositories, but app only needs to import schema. Follow the three files below to see how the implementation keeps those two responsibilities separate. These module excerpts illustrate the contracts with placeholder URLs and checksums.
This example assumes ordinary, non-development extension uses. Its metadata therefore lists the root's requested repositories under root_module_direct_deps, with an empty development list. An extension that supports development-only uses must also classify those tags with ctx.is_dev_dependency(tag) when computing its metadata.3
app/MODULE.bazelmodule(name = "app")bazel_dep(name = "helper", version = "1.0")bazel_dep(name = "artifact_rules", version = "1.0")archives = use_extension( "@artifact_rules//:extensions.bzl", "archives",)archives.artifact( name = "schema", url = "https://example.org/schema.json", sha256 = "a" * 64,)use_repo(archives, "schema")helper/MODULE.bazelmodule(name = "helper", version = "1.0")bazel_dep(name = "artifact_rules", version = "1.0")archives = use_extension( "@artifact_rules//:extensions.bzl", "archives",)archives.artifact( name = "schema", url = "https://example.org/schema.json", sha256 = "a" * 64,)archives.artifact( name = "helper_data", url = "https://example.org/helper.json", sha256 = "b" * 64,)use_repo(archives, "schema", "helper_data")artifact_rules/extensions.bzlload( "@bazel_tools//tools/build_defs/repo:http.bzl", "http_file",)_artifact = tag_class(attrs = { "name": attr.string(), "url": attr.string(), "sha256": attr.string(),})def _archives_impl(ctx): root_repos = [] seen = {} for mod in ctx.modules: for tag in mod.tags.artifact: key = (tag.url, tag.sha256) if tag.name in seen: if seen[tag.name] != key: fail( "conflicting request: " + tag.name, ) continue seen[tag.name] = key if mod.is_root: root_repos.append(tag.name) http_file( name = tag.name, url = tag.url, sha256 = tag.sha256, ) return ctx.extension_metadata( root_module_direct_deps = root_repos, root_module_direct_dev_deps = [], )archives = module_extension( implementation = _archives_impl, tag_classes = {"artifact": _artifact},)
1
Two modules use the same extension
Run Bazel from the app project: app is the root module for this invocation. Its dependency helper is another module. The root is the project being built, not the module that defines the extension.
Both callers use archives from the same artifact_rules module. Bazel evaluates that extension with tags from both callers. These are illustrative module excerpts with placeholder URLs and checksums, not a downloadable fixture.
2
Tags request files, use_repo imports their names
app requests schema. Open helper/MODULE.bazel: helper requests the same schema and an additional helper_data. Each archives.artifact(...) supplies a tag matching the extension’s declared schema.
Each module also writes its own use_repo(...) call. This imports generated repository names into that module, allowing labels such as @schema//file. The requests and imports are separate operations.
3
Bazel supplies the resolved module graph
Expression
ctx
Type
module_ctx
ctx.modules is the breadth-first list of modules using this extension, with each module's tags grouped by tag class.
When
After module resolution, normally when a generated repository is needed. This is separate from configured-target analysis.
Effect
Aggregate tags and declare repositories. This callback does not analyze build targets or register their actions.
mod.is_root for app
True, because Bazel was invoked in app.
mod.is_root for helper
False in this build. Building helper as its own project would make helper the root instead.
4
Record imports expected in app only
root_repos is our local bookkeeping list for the root module’s expected imports. It is not the list of every repository the extension will declare.
Request from app: schema
mod.is_root is True, so append schema to root_repos.
Request from helper: helper_data
mod.is_root is False, so do not append it. The http_file(...) call below is outside this condition and still declares the repository.
For this build the final list is ["schema"]. Adding helper_data would incorrectly tell Bazel that app should import a repository used only by helper.
5
Resolve repeated repository requests
Bazel visits the root first in this example. Its schema request is recorded in seen and declared once. The same request from helper reaches continue, so it creates no second declaration.
Equal schema request
The name, URL and checksum agree. Keep the existing declaration.
Alternative: helper changes the checksum for schema
The same name now refers to different contents. fail() stops evaluation.
New helper_data request
Its name is not in seen. Record it and declare it. Skip only the root-specific append.
6
Repository rules materialize the resolved plan
http_file(...) declares a repository for each distinct accepted request, regardless of which module submitted it. The root-only condition above controls metadata bookkeeping, not this call.
Repositories declared
schema and helper_data.
root_repos
Only ["schema"].
Fetching
The repository rule downloads and exposes a file when that repository is fetched. The extension’s declaration alone is not the download.
7
Metadata checks app’s use_repo call
Expression
ctx.extension_metadata(...)
Type
extension_metadata
The callback returns metadata saying that app should directly import schema. It does not return the repositories themselves.
Compare the two highlighted files: root_module_direct_deps = ["schema"] agrees with app’s use_repo(archives, "schema"). Bazel uses this information to diagnose missing or unnecessary imports in the root module. bazel mod tidy can update those imports.
The word direct here means repositories that app should import from this extension. It is not app’s bazel_dep list. The empty root_module_direct_dev_deps list reflects this example’s non-development uses.
8
helper keeps its own imports
helper imports both repositories into its own scope. Its BUILD targets can refer to @helper_data//file even though app does not import that name. app can build helper’s targets without importing every repository that helper uses.
This is the same boundary introduced in 4.10.1 Module Extension Fundamentals, but now the important point is control flow: the implementation loops over modules, inspects tags, validates conflicts, and calls a repository rule such as http_file. Repository rules remain the mechanism that materializes external repositories. The extension is the graph-aware layer that decides which calls to make.4 For the repository-rule API itself, keep the details in 4.9.4 Repository Rule API.
The mini-ruleset's complete _glyph_deps_impl follows the same loop: it aggregates requests across module_ctx.modules, rejects conflicting versions, calls one repository rule per resolved package, and returns only the root module's direct repos in extension_metadata().
Reading module_ctx.modules
module_ctx.modules is the extension's view of the external dependency graph. It contains only modules that use this extension, and each entry is a bazel_module object with fields such as is_root, name, version, and tags.5 The list order is guaranteed to be breadth-first from the root module, which means the root appears first, but production code is clearer when it uses mod.is_root for root-specific policy instead of relying on a positional convention.6
Tags are grouped by tag class. If the extension declaration contains tag_classes = {"artifact": _artifact}, then a module's artifact tags are available as mod.tags.artifact. When an extension must merge tags from different classes while preserving the module-file and breadth-first ordering model, module_ctx.tag_sort_key(tag) gives sorted(..., key = ...) an opaque stable key for that ordering.6 This is why a repository-rule API often changes shape when it becomes an extension API: a single list attribute such as packages = [...] in a repository rule can become many per-module tags, so each dependency can state its own direct need and the extension can combine them later.7
The implementation defines the conflict policy. Some extensions choose the highest requested version across all modules. Others let only the root module set global configuration, or allow dependency modules to add packages but not override settings. The Haskell and Nix migration examples use this distinction to avoid the old WORKSPACE problem where macro order changed which dependency configuration won.8
Calling Repository Rules
Inside the implementation function, repository rules are ordinary loaded Starlark symbols. Calling http_file(name = ..., ...) or a custom repository rule does not create a target in the current package. It declares an external repository that will be part of this extension's repository namespace.9
The common pattern is:
Collect all relevant tags from ctx.modules.
Normalize and validate them into one resolved plan.
Call repository rules to create the repositories in that plan.
Return ctx.extension_metadata(...) when Bazel should help maintain use_repo() declarations.
A Go toolchain extension follows exactly this flow: read all download tags, select the highest requested Go version, fetch a manifest, instantiate per-platform download repositories, and create a toolchain repository that exposes the selected toolchains.10 The important lesson is not Go-specific. The extension can make one graph-wide decision, then delegate file creation, downloads, and generated BUILD files to repository rules.
module_ctx also has repo-rule-like helpers such as download, read, execute, file, path, watch, and which.11 Use them when the extension itself must resolve data before it can decide which repositories to create. Keep the host-dependent parts narrow: execute(), os, path(), and unchecked downloads quickly become hermeticity concerns, which is the focus of 4.10.6 Hermeticity Considerations.
Returning extension_metadata
use_repo() is not decoration. Module extensions are lazy: Bazel normally evaluates an extension only when some repository it generates is brought into scope and then referenced. Since Bazel cannot know every generated repo name before evaluating the extension, the caller names the repos it expects with use_repo().12
That creates a maintenance problem for extensions that generate many repositories. ctx.extension_metadata() is the author-side answer: the implementation can return the repositories it considers direct dependencies of the root module, split into normal and dev dependencies. If the root module's use_repo() calls do not match, Bazel warns and tells the user to run bazel mod tidy, which can update those calls automatically.3
In the walkthrough, http_file(...) declares schema and helper_data. The condition if mod.is_root guards only root_repos.append(tag.name). It records schema, requested by app, but skips helper_data, requested only by helper. The repository declaration below the condition still runs for helper’s request.
The returned root_module_direct_deps = ["schema"] tells Bazel what app should import from this extension. It agrees with app’s use_repo(archives, "schema"). If we added helper_data to that metadata, Bazel would incorrectly recommend importing helper’s internal repository into app too. The dependency keeps its own use_repo(archives, "schema", "helper_data") call and can use both names in its own BUILD files.3
Metadata does not import repositories or restrict which repositories other modules may import. Each module’s use_repo() call controls its own imports. This metadata checks the root module’s calls. Here, “direct dependencies” means generated repositories expected in those calls, not the modules listed in bazel_dep(...).
key takeaway
The implementation function is the module extension's resolver. Treat module_ctx.modules as the input stream, repository-rule calls as the output plan, and extension_metadata as the maintenance contract with the root module's use_repo() list.
extra
Two Boundaries To Keep Separate
A module extension can load() repository rules and helper code from repositories already visible to the module that hosts the extension, but it cannot create a repository and then load() from that same newly-created repository inside the same extension. The fix is to split configuration generation and dependency creation into separate extensions, avoiding a circular repository definition.13
extension_metadata(facts = ...) is also deliberately out of scope for the basic implementation loop. Facts persist data across extension reevaluations and are useful for stateful resolution. They are covered next in 4.10.3 Facts (State Persistence).14
Check your understanding · 4 questions
1.Which responsibilities belong specifically inside a module extension implementation function?
Select all that apply
2.Which field should implementation code use for root-module-specific policy?
Select one answer
3.True or false: extension_metadata() and use_repo()
Choose True or False for each sentence
ctx.extension_metadata(root_module_direct_deps = ...) can tell bazel mod tidy which generated repos should appear in root use_repo() calls.
Returning extension_metadata() makes those repositories visible even when use_repo() is missing.
root_module_direct_deps and root_module_direct_dev_deps separate normal and dev-only repository imports.
extension_metadata() replaces the need to call repository rules from the implementation.
4.Why cannot a module extension implementation return DefaultInfo for a generated repository?
Select one answer
0 of 4 answered
Footnotes
Module extensions — extension definition and implementation function overview. ↩
.bzl files — module_extension() signature and implementation parameter. ↩
module_ctx — extension_metadata() parameters and bazel mod tidy integration. ↩1↩2↩3