Gazelle in a large monorepo is slow. Like, 30-seconds-of-staring-at-your-terminal slow. Set one environment variable and it drops to 3 seconds. That variable is ASPECT_GAZELLE_CACHE, and today I dug into what it actually does under the hood.
The one-liner that saves 27 seconds
In your .bazelrc:
common --repo_env=ASPECT_GAZELLE_CACHE=.cache/aspect-gazelle.cache
That’s it. Gazelle now persists a file-based cache between runs. Unchanged files get skipped entirely — no re-parsing, no re-resolving. The parser is the expensive part, so skipping it is where the time savings come from.
Where the code lives (not where you’d expect)
I spent a while grepping through rules_py looking for cache logic. It’s not there.
The cache lives in aspect-build/aspect-gazelle — the pre-compiled gazelle binary that Aspect distributes. Key files:
| File | Role |
|---|---|
common/cache/configurer.go |
Plugs into Gazelle’s config system |
common/cache/disk.go |
Gob-encoded disk persistence |
runner/pkg/watchman/cache.go |
Watchman-aware cache for watch mode |
Cache key: content + version
The cache key for each file is:
MD5(gitCommit + fileContent)
Two things invalidate a cache entry:
- File content changes — you edited the file, hash changes, entry invalidated
- Gazelle version changes — the
gitCommitof the gazelle binary itself is baked into the key, so upgrading gazelle blows the whole cache
This is clever. You don’t need a separate “cache version” mechanism or manual invalidation. If gazelle’s parser changes behavior in a new release, every cached entry becomes a miss automatically.
The lifecycle
On startup, the binary reads the gob-encoded cache file from disk into memory. Then during the run, every time gazelle needs to process a source file, it calls LoadOrStoreFile():
LoadOrStoreFile(path, contentHash, loaderFunc)
→ cache hit? return stored result, skip parser
→ cache miss? call loaderFunc (the actual parser), store result
On shutdown, Persist() writes the updated map back to disk. One read, one write, everything else is in-memory lookups.
How it plugs in
This is the part I found most interesting architecturally. The cache isn’t bolted on as an afterthought — it’s wired in as a Gazelle config.Configurer, the same extension point that languages use.
The flow:
CheckFlags()— readsASPECT_GAZELLE_CACHEenv var, creates the cache instance- During generation — language extensions call
cache.Get(config)to access the cache, useLoadOrStoreFile()for each source file DoneGeneratingRules()— callsPersist()to flush to disk
Using the Configurer interface means the cache participates in gazelle’s normal lifecycle without any special-case plumbing. It’s just another configurer, like a language extension.
Watchman mode: even smarter
In watch mode (when you have a watchman daemon running), the cache gets an upgrade. Instead of hashing file contents to detect changes, it uses watchman’s clock specs — essentially “what changed since the last time I asked?”
This means:
- No content hashing at all for unchanged files — watchman already knows they haven’t changed
- Directory walk state is cached too — gazelle doesn’t re-walk directories that watchman says are untouched
The watchman-aware cache (runner/pkg/watchman/cache.go) wraps the base cache and adds this clock-based invalidation. In a monorepo with thousands of directories, not re-walking untouched subtrees is a huge win on top of not re-parsing unchanged files.
The takeaway
ASPECT_GAZELLE_CACHE is a “don’t re-parse unchanged files” optimization. The implementation is clean — content-addressed keys with version pinning, gob encoding for fast serialization, and the Configurer interface for zero-friction integration. If you’re using Aspect’s gazelle binary and haven’t set this, you’re leaving a 10x speedup on the table.