Learn · Learning Bazel
seedling
What the Cache Pays Back
Four lines of configuration, a build whose every output you have just deleted, and the moment the discipline of the first eleven chapters turns into a number.
By the end you will have a cache, three measurements, and a clear account of why the earlier chapters were the price of this one.
Four lines
Create a build configuration file at the workspace root:
.bazelrc
# A shared cache keyed by the exact inputs of each action. Everything
# earlier in this book was the price of admission: a cache is only safe
# if the key really does capture everything the action read.
build --disk_cache=~/.cache/learning-bazel
# Show what came from cache rather than being rebuilt.
build --show_result=0A directory to keep results in, and a quieter output. That is the entire configuration.
A cache is a store of results keyed by inputs. Before running any step, the build computes a key from everything that step depends on — the source files, the compiler, the flags, the dependencies' outputs — and asks whether that key has been seen. If it has, the stored result is the answer.
The soundness of that is entirely a claim about the key.
If the key misses something the step read, the cache returns an answer computed from different inputs — a wrong build, served fast and confidently. This is chapter one's failure with a much better engine behind it. Everything since then has been about making the key complete.
Measure the cold build
Start with the cache empty:
$ bazel build //...
INFO: Elapsed time: 32.117s
INFO: 135 processes: 52 internal, 83 darwin-sandbox.A cold build with an empty cache. Your own number will differ — it depends on your machine and whether the toolchains are already downloaded; what matters is the comparison that follows.
Thirty-two seconds, 135 processes. This is the honest cost of building this project from nothing.
Destroy everything
Now throw away every output:
$ bazel clean
INFO: Starting clean.A full clean. All build outputs are gone — this is the state a fresh clone or a new build machine starts from.
This step matters, and skipping it is how caching demonstrations mislead. Building twice in a row proves only that the build remembers what it just did. Deleting the outputs first is what shows the cache is a real store, keyed by inputs, that survives the loss of everything else.
Measure again
$ bazel build //...
INFO: Elapsed time: 1.364s
INFO: 135 processes: 82 disk cache hit, 52 internal, 1 darwin-sandbox.The same build, with nothing on disk and a warm cache. Eighty-two steps were answered from the store rather than executed.
Thirty-two seconds to under one and a half. Twenty-three times faster, having deliberately destroyed the output tree first.
Read the process line rather than the clock, because it is the more honest number: 82 of the steps were not run. Not run faster — not run. The build found their keys in the store and used the answers.
And the everyday case
The dramatic number is the cold one. The number you feel daily is what happens after a small edit:
$ echo everybody > server/greeting/name.txt
$ bazel build //...
INFO: 6 processes: 7 action cache hit, 3 internal, 3 darwin-sandbox.
$ bazel run //server
Hello EverybodyEditing one embedded data file. Six processes instead of 135 — only the steps downstream of that file had keys that changed.
Six processes. Everything else kept its key, so everything else kept its answer — including the entire TypeScript side, which has nothing to do with a Go data file and was correctly left alone.
Compare that with chapter one, where editing name.txt produced zero work and a wrong answer. Same edit, same file name, opposite outcome: the build now knows that file matters and knows exactly which steps it matters to.
A kitchen that keeps every dish it has ever made, labeled with the exact ingredients. Ask for something it has made before with those exact ingredients and it hands it straight over. That only works if the labels are complete — one unlisted pinch of salt and you get somebody else's dinner.
The cache serving a wrong answer
Everything above depends on the key being complete. Here is what it looks like when it is not, and it is worth doing once so the guarantee stops being abstract.
A step that reads the clock:
genrule(
name = "clock",
outs = ["clock.txt"],
cmd = "date +%s%N > $@",
)The action's declared inputs are nothing at all — and it produces a different answer every time it runs.
Build it, throw away every output, build again:
$ bazel build //stamp:clock && cat bazel-bin/stamp/clock.txt
1786186502062042000
$ bazel clean && bazel build //stamp:clock && cat bazel-bin/stamp/clock.txt
1786186502062042000The same nanosecond timestamp, minutes apart. The second build never ran the command.
Nothing malfunctioned. The action declared no inputs, so its key never changed, so the cache correctly concluded it had this result already. The cache did exactly what a cache does — the declaration was the lie.
This is chapter one's Makefile, arriving at the end of the book with a much better engine behind it. An undeclared input still produces a confidently wrong answer; the difference is that here you had to work to create one, and there it happened by writing a normal recipe.
The honest fix is to say so:
tags = ["no-cache"],no-cache tells the build this result cannot be reused, because its key does not capture what it depends on.
Now the timestamp differs on every build, because the command runs on every build. You have traded the speed for a correct answer, which is the right trade and — more importantly — a stated one.
Real examples are rarer than they sound: the clock, a network fetch, a random seed, a machine's hostname. If you find yourself reaching for no-cache often, the thing to examine is usually the action, not the tag.
Where this goes next
The cache here is a directory on your machine. The same mechanism points at a shared server, and then the numbers stop being about you.
A colleague builds main. You pull it and build: their results are your cache hits, because the keys are computed from inputs rather than from who ran the job. Your continuous-integration system stops rebuilding what a developer already built. None of that needs new concepts — it is this chapter with the store somewhere else.
Sharing a cache across machines is where hermeticity stops being theoretical. If one machine's compiler differed from another's, shared results would be wrong — which is why the compiler was pinned as an input back in chapter two rather than treated as something the machine provides.
That is also where the honest limit sits: a shared cache is exactly as trustworthy as the completeness of its keys. The next book in this series is about running that at scale. This one has done the part that makes it possible.
A cache stores results keyed by inputs, so a step whose inputs have been seen before is answered rather than run. Measured here: 32.1 seconds cold, 1.4 seconds after deleting every output, and 6 processes instead of 135 after a one-file edit. The demonstration destroys the outputs on purpose — a second build without cleaning proves nothing. Soundness depends entirely on the key being complete, which is what the sandbox bought.
Try this in your own repository
Time a cold build. Delete every cache and artifact you can find, then build and measure. Most teams do not know this number, and it is the one a new colleague experiences on their first day.
Look for an action whose key is incomplete. Anything reading the clock, the network, a hostname, or a random seed. If your build caches results, each of those is a wrong answer waiting for the right circumstances — and if it does not cache, they are the reason it cannot.
What you can now do
Enable a cache, measure what it saves, and explain to a sceptical colleague why the guarantee is sound rather than a gamble. The next chapter is the one this book was written for.