Every external dependency in Bazel — every http_archive, every pip.parse package, every module from the Bazel Central Registry — gets a marker file. It’s a small file that Bazel checks on startup to decide: “Do I need to re-download this, or is the cached version still good?” Nobody talks about these files. The documentation barely mentions them. But they’re the mechanism that controls whether bazel build takes 3 seconds or 3 minutes.
Where they live
Marker files sit in the external/ directory of your Bazel output base:
ls $(bazel info output_base)/external/*.marker
@bazel_tools.marker
@rules_python+.marker
@rules_python++pip+pypi_312_click.marker
@rules_python++pip+pypi_312_numpy.marker
# ... hundreds more
One marker file per external repository. The name matches the repository name with a .marker suffix. Every time Bazel starts, it reads every single one of these files and checks whether the corresponding repository is still valid.
Three sizes of marker file
Not all marker files are equal. Their complexity scales with the type of repository they track:
| Size | Type | What’s Inside | Example |
|---|---|---|---|
| ~65 bytes | Simple generated repo | Just a hash | @bazel_tools.marker |
| ~118 bytes | BCR http_archive | Hash + ENV lines | @rules_python+.marker |
| 4KB+ | Module extension generated | Hash + FILE deps + REPO_MAPPING | @rules_python++pip+pypi_312_click.marker |
Let’s look at each one.
Simple: just a hash
# @bazel_tools.marker
fda8a6529e74f9d7...
That’s it. One hash. If this hash matches what Bazel computes for the current inputs, the repository is valid. If not, re-create it. These are for repositories that Bazel generates internally — built-in tools, platform definitions. They rarely change.
Medium: hash + environment
# @rules_python+.marker
8af61aed2c5b...
ENV:PATH=/usr/local/bin:/usr/bin
ENV:HOME=/Users/shiyuanzheng
The hash covers the repository rule’s input parameters — the URL, the integrity hash, any patches. The ENV lines list environment variables that the repository rule declared as inputs via environ in its rule definition.
If you change your PATH and a repository rule depends on PATH, the marker file detects the mismatch and triggers a re-download. This is how Bazel handles repository rules that use tools from the host system — the marker tracks which environment state the cached repository was built against.
Complex: the full dependency graph
# @rules_python++pip+pypi_312_click.marker
a3f9b21e7c8d...
FILE:@@rules_python++pip+//:requirements.txt=sha256:e4b2c1d8...
FILE:@@rules_python++pip+//:BUILD.bazel=sha256:f1a2b3c4...
REPO_MAPPING:@@rules_python++pip+,rules_python,rules_python+
REPO_MAPPING:@@rules_python++pip+,bazel_tools,bazel_tools
This is where it gets interesting. Module extension-generated repositories track everything:
FILE lines: Content hashes of files that the repository rule read during creation. If requirements.txt changes, this hash changes, the marker invalidates, the repository re-generates. This is how pip.parse knows to re-resolve when you add a dependency.
REPO_MAPPING lines: Records which repository names were mapped to which actual repositories during creation. If a mapping changes — say, rules_python gets upgraded and the canonical name shifts — the marker detects it.
These markers are 4KB+ because a single pip package might depend on a dozen files and have 20+ repo mappings. Multiply by hundreds of pip packages and you have a significant chunk of I/O on every Bazel startup.
The validation algorithm
On startup, Bazel processes every marker file through the same logic:
for each .marker file:
1. Read the file
2. Compute current hash of the repository rule's inputs
3. Does main hash match? → if no, invalidate
4. For each ENV line:
- Read current environment variable
- Does value match? → if no, invalidate
5. For each FILE line:
- Read current file, compute sha256
- Does hash match? → if no, invalidate
6. For each REPO_MAPPING line:
- Resolve current mapping
- Does mapping match? → if no, invalidate
7. All match? → skip download, use cached repository
8. Any mismatch? → re-execute repository rule (download/generate)
This is conservative. Any single mismatch triggers a full re-execution of the repository rule. There’s no partial invalidation — you don’t get “re-download the tarball but keep the extracted files.” The entire repository is either valid or it’s not.
The performance implication: Bazel reads every marker file sequentially on startup. With 500 external repositories (not unusual for a large Python monorepo with many pip packages), that’s 500 file reads and potentially hundreds of file hash computations before Bazel even starts thinking about your actual build.
The two different hashes
This confused me until I traced the code. A marker file might reference two seemingly different hashes:
# In MODULE.bazel cache: fda8a652...
# In the marker file: 8af61aed...
These are not the same thing, and they’re not supposed to be.
fda8a652 is the sha256 of the MODULE.bazel file’s content. It’s stored in the module cache at ~/.cache/bazel/_bazel_user/repos/.... This tracks “has the dependency declaration changed?”
8af61aed is the hash of the repository rule’s complete input parameters — the URL, the integrity checksum, strip_prefix, patches, environment variables, everything. This is what the marker file checks. It tracks “has anything that affects the downloaded result changed?”
They’re different because they track different levels of the dependency chain. The MODULE.bazel hash tracks the declaration. The marker hash tracks the resolution. You can change MODULE.bazel in a way that doesn’t affect the resolved URL (e.g., reformatting a comment), and the marker hash stays the same. You can change the resolved URL without changing MODULE.bazel (e.g., a registry redirect), and the marker hash changes.
Four caching layers
Marker files are one piece of a four-layer caching system:
| Layer | Location | Size | What It Stores |
|---|---|---|---|
| 1. Module cache | ~/.cache/bazel/_bazel_user/repos/.../MODULE.bazel |
Small | Dependency declarations |
| 2. Marker files | $(output_base)/external/@xxx+.marker |
Small | Cache validation metadata |
| 3. Tarball cache | ~/.cache/bazel/_bazel_user/repos/.../file |
Large | Downloaded archives |
| 4. Extracted repos | $(output_base)/external/@xxx+/ |
Large | What Bazel actually uses |
The flow:
- Bazel reads MODULE.bazel to determine what dependencies are needed
- For each dependency, checks the marker file against current state
- If marker is valid → use the extracted repo in layer 4, done
- If marker is invalid → check if tarball exists in layer 3
- If tarball exists → re-extract (fast), update marker
- If tarball missing → re-download (slow), extract, update marker
Layer 2 (marker files) is the decision point. It determines whether you hit layer 3 (fast path — local extraction) or need a network fetch (slow path). This is why marker file validation speed matters — it’s the gate between “instant startup” and “downloading the internet.”
Where the code lives
If you want to read the actual implementation, it’s in the Bazel Java core. Not in Starlark, not in repository rules — in the Java source of Bazel itself:
| File | Role |
|---|---|
RepositoryDelegatorFunction.java |
Orchestrates the marker file check → fetch → extract flow |
RepositoryFunction.java |
Base class for repository rule execution |
ResolvedHashesFunction.java |
Computes and validates the hashes |
This code runs at analysis time, before any build actions execute. It’s part of Bazel’s “loading phase” — the phase where Bazel figures out what the dependency graph looks like before deciding what to build.
The fact that this is Java and not Starlark means you can’t customize it. You can’t write a repository rule that uses a different marker format or a smarter invalidation strategy. The marker mechanism is hardcoded. Repository rules only control what gets fetched — the marker validation logic is Bazel’s internal concern.
Practical implications
Why bazel clean --expunge is nuclear
bazel clean --expunge deletes the entire output base, including all marker files and extracted repositories. The next bazel build re-downloads everything. Every http_archive, every pip package, every module. In a large project, this can take 10-20 minutes just for dependency fetching.
bazel clean (without --expunge) only removes build outputs, not external repositories. The marker files survive. This is almost always what you want.
Why environment changes cause re-fetches
Switch from one terminal to another with a different PATH? If any repository rule declared environ = ["PATH"], the marker file detects the change and triggers a re-fetch. This is why Bazel sometimes re-downloads dependencies for no apparent reason — something in your environment changed that a repository rule cares about.
The fix: minimize environ usage in repository rules. Every declared environment variable is a potential invalidation trigger.
Why adding one pip package re-resolves all of them
When you add a package to requirements.txt, the FILE hash in every pip-related marker file changes (because they all depend on requirements.txt). Every pip repository invalidates. pip.parse re-runs, re-resolves the full dependency tree, re-generates all repositories.
This is correct behavior — adding a package can change the resolved versions of other packages through transitive constraints. But it means a one-line change to requirements.txt triggers a full pip re-resolution, which can take minutes in a large project.
The invisible mechanism
Marker files are infrastructure that works best when you never think about them. Fast Bazel startup? Markers validated instantly. Slow startup? Markers found mismatches and triggered re-fetches. Mysterious re-downloads? A marker detected an environment or file change you didn’t notice.
Every “why is Bazel re-downloading X” question starts with reading the marker file. The answer is always in there — which hash changed, which file was modified, which environment variable shifted. The marker file is Bazel’s debug log for dependency caching, written in a format that’s inscrutable until you know what each line means.
Now you know what each line means.