Build Cache Invalidation — Timestamps vs Content Hashes
Timestamps vs Content Hashes
Developer toolingdeveloper tooling / build systems
The myth
"My build is incremental, so it only rebuilds what actually changed."
Actually
Classic make has no idea what changed. It compares mtime — one number per file — and rebuilds a target if any prerequisite is newer than it. That's the whole algorithm. File contents are never read.
Three consequences you've already lived through:
git checkout other-branch && git checkout -sets mtime = now on every touched file. Full rebuild, even though the tree is byte-identical to what you compiled 30 seconds ago.touch main.c→ rebuild. Add a comment and delete it → rebuild.- The scary one, inverted: if a source is written in the same clock tick as its .o, make sees "not newer" and SKIPS it. You ship a binary built from code you already deleted. Filesystems with 1-second timestamp granularity (some NFS mounts, certain CI volume drivers) hit this. ext4 is nanosecond — which is why it's rare on your laptop and not rare in CI.
The other model: content hashing
Bazel, Gradle, Nx, Turborepo, ccache/sccache never ask "is it newer?" They ask "have I seen exactly these inputs before?"
key = hash(source bytes + compiler flags + toolchain version + declared env)
Revert your change → same hash → instant hit. Build on a teammate's machine → same hash → remote cache hit. Timestamps are irrelevant.
The gotcha that separates intermediate from beginner
Hash-based caching is correct only if EVERY input is declared. An undeclared input doesn't make you slow — it makes you wrong, silently.
Classics:
- $CC pointing at a different gcc minor version, or a system header outside the sandbox
- A build-time env var (NODE_ENV, GIT_SHA) not folded into the key
__DATE__/__TIME__in C — exactly why ccache ships CCACHE_SLOPPINESS=time_macros- A test task whose key covers src/ but not fixtures/ → green "cache hit" on a test that never ran against the new fixture
Timestamp staleness costs you minutes. Hash staleness costs you a bad release.
Commands worth having in your fingers
make --debug=b # prints WHY each target was considered out of date
ccache -z && make && ccache -s # zero stats, build, read hit rate
ls --time-style=full-iso -l main.c main.o
bazel aquery 'deps(//app)' # the exact declared inputs per action
Your exercise
In any repo with a Makefile (or a ccache-backed build):
- Build clean, then build again. Confirm the second one is a no-op.
touchone source file — don't edit it. Build. Did it rebuild? Say out loud why.ccache -z, build from scratch,ccache -s. Thenmake clean, build again,ccache -s.
What's the hit rate on that second build, and what does that number prove about mtime vs content?
Bonus, harder: name one input your CI build genuinely depends on that is NOT in your cache key today.