4.10.3 Facts (State Persistence)
extraFacts are persisted data that a module extension writes into Bazel's module lockfile and reads back during later evaluations. They are for facts that remain true outside the current run, such as a version-to-checksum mapping discovered from an immutable upstream source, not for arbitrary extension-local cache state.1
Facts belong to module-extension evaluation, not target analysis. On a later extension evaluation, Bazel can supply a matching stored value before the extension repeats a network discovery. Changing ordinary target sources or actions does not make that fact disappear. Conversely, changing the extension implementation alone does not invalidate facts. The explicit invalidation boundary is facts_version. Bump it only when the extension can no longer safely read the stored schema.1,2
The previous item treated ctx.extension_metadata(...) as the way an extension describes generated repositories for bazel mod tidy in 4.10.2 Implementation Function. facts uses the same return value for a different purpose: carrying selected resolution results forward so the next extension evaluation can avoid rediscovering them from the network or another external source.3
The Shape Of A Fact
An extension returns facts from its implementation function:
def _sdk_impl(ctx):
version = "1.2.3"
sdk = ctx.facts.get(version)
if sdk == None:
sdk = _resolve_sdk_from_upstream(version)
_sdk_repository(
name = "sdk",
url = sdk["url"],
sha256 = sdk["sha256"],
)
return ctx.extension_metadata(
reproducible = True,
facts = {
version: {
"url": sdk["url"],
"sha256": sdk["sha256"],
},
},
)
The facts parameter must be a dict with string keys and JSON-like Starlark values. Bazel makes data returned this way available to future executions through module_ctx.facts. If there are no previous facts, that field behaves like an empty value for lookup purposes.4
A canonical example is an SDK resolver: first evaluation fetches a mapping from SDK version to URL and checksum. Later evaluations can read that mapping from facts instead of repeating the network request.5 That example is intentionally about data that is effectively immutable. If the upstream value can change under the same key, storing it as a fact will preserve the old value and make the extension believe it is still true.
Facts Are Lockfile Data, Not A General Cache
Facts are always persisted in MODULE.bazel.lock.5 That makes them useful when the extension wants to become reproducible even though it had to learn something from outside the build the first time. The point is to store immutable data from outside the build, such as internet-derived data, so later evaluations can use it for smarter caching and checksumming.6
This is narrower than normal repository-rule reproducibility in 4.9.5 Reproducibility Concerns. A checksum-guarded archive download usually does not need facts: the checksum already names the expected content, and Bazel's repository cache can participate directly. Facts fit the awkward middle case where the extension has to discover stable metadata before it can create checksum-guarded repositories.
That distinction matters for review. A fact should read like "for version 1.2.3, the immutable archive is at this URL with this checksum." It should not read like "last time I ran, I chose this mirror" or "here is a timestamped response body." If the value is not stable enough to treat as a fact, keep the extension non-reproducible or redesign the source of truth.
The Read API Is Deliberately Small
module_ctx.facts is dict-like, but it is not a full dict. It supports direct lookup, get(), and membership tests such as "1.2.3" in ctx.facts. It does not support iteration, keys(), items(), or len().7
That limitation is part of the design. Bazel may shallowly merge facts dicts produced by different versions of the same extension to resolve lockfile merge conflicts, using behavior like dict.update() or Starlark's | operator.8 If extension code could enumerate all stored facts, it would be tempting to make decisions depend on the whole persisted set. Lookup-only access nudges authors toward key-value storage where each key independently determines its value.
If shallow merging is not valid for your data, use a single fixed top-level key and put your own structured value under it. That opts out of useful per-key merging, but it avoids pretending two independently generated dictionaries can be combined safely.8
Version Your Schema
Facts can outlive the exact extension implementation that wrote them. A different version of the extension may read the value back later, so either include a version number or use a schema unlikely to become ambiguous.8
A simple pattern is to make the schema explicit in the stored value:
return ctx.extension_metadata(
facts = {
"sdk:1.2.3": {
"schema": 1,
"url": sdk["url"],
"sha256": sdk["sha256"],
},
},
)
The key should still identify the immutable fact, and the value should contain enough shape information for future code to reject or migrate old entries deliberately. This is especially important because facts are not invalidated just because the module extension's code changes.5 When the extension changes its facts schema incompatibly, set or bump module_extension(facts_version = ...): Bazel persists that schema version in the lockfile and discards old facts when the recorded version no longer matches.2
Use facts only for stable external truths that help a module extension become reproducible on later evaluations. Return them through extension_metadata(facts = ...), read them through module_ctx.facts, design keys so each one independently determines its value, and bump module_extension(facts_version = ...) when an incompatible schema change means old persisted facts must be discarded.
Check your understanding · 3 questions
1.What is the right use for extension_metadata(facts = ...)?
Select one answer
2.Which operations are supported on module_ctx.facts?
Select all that apply
3.True or false: designing facts for module extensions.
Choose True or False for each sentence
module_extension(facts_version = ...) lets Bazel discard old facts recorded under the previous schema version.Footnotes
-
Module extensions — best-practices guidance for using
factswith immutable external data. ↩1 ↩2 -
.bzl files —
module_extension(facts_version = ...)persists the facts schema version and discards older facts when the version changes. ↩1 ↩2 -
module_ctx —
extension_metadata()includes both repo-maintenance parameters and thefactsparameter. ↩ -
module_ctx —
factsparameter andmodule_ctx.factsfield behavior. ↩ -
Module extensions — facts are persisted in the lockfile, available to future evaluations, not invalidated by extension code changes, and illustrated with an SDK mapping example. ↩1 ↩2 ↩3
-
State of the Union - John Field, Engineering Manager & Tobias Werth, Software Engineer, Google — product framing for the Facts API. ↩
-
Facts — dict-like access,
get(), membership tests, and lack of iteration APIs. ↩ -
module_ctx — shallow merge behavior, key-value guidance, merge opt-out pattern, and schema-version caution. ↩1 ↩2 ↩3