Cachau
Delightful, observable, bounded, and persistent function caching for Python data workloads.
Cachau is a function cache designed around the real problems of data science: large arguments, expensive computations, notebooks that restart, voluminous results, invalidation when code or data changes, and explicit memory and disk limits.
Say ciao to recomputation.
from cachau import cache
@cache(ttl="1h", persist=True, max_memory="2GB")
def expensive_analysis(df, config):
...
Status: v0.7.0 — the core engine plus validated Numba Level A support (472 tests, CI on 3.10-3.13 plus free-threaded 3.13t/3.14t): normalized keys with type-tagged hashing (incl. closure captures), native NumPy/pandas/Polars identity,
key=/ignore=escape hatches (incl.array_token()for big immutable args), code-change invalidation, TTL, LRU memory bounds that survive restarts, atomic corruption-safe persistence, same-key single-flight (in-process and cross-process viacoalesce="processes", validated with real-process crash/wedge stress),stats()with miss reasons and cold/warm JIT accounting,explain()(with eviction, dependency-diff, and write-failure detail),inspect(),depends_on=external-dependency invalidation (files, env vars, package versions, custom tokens, helper implementations viacode()),verify=sampled hit verification,profile()(measured cache economics), andcachau.testingcertification assertions. Pre-1.0, so the API may still evolve. Next up: notebook polish (see ROADMAP).Upgrading from 0.6.x: v0.7.0 fixes a key collision that ignored an argument's concrete type — a
bytearraykeyed asbytes, a NamedTuple as a plain tuple, any builtin or array/frame subclass as its base — so two calls with differently-typed arguments could share one entry (a false HIT). Only the values that were colliding get a new key: entries for exact builtins keep their digest, so existing persisted caches stay readable and re-miss once for the affected arguments. No action needed.Upgrading from 0.2.x: v0.3.x fixes a fingerprint collision that could serve one function's result for another (a false HIT). Closing it changes how every function's identity is computed, so existing persisted caches are invalidated once — the first run after upgrading recomputes and reclaims the old files automatically. No action needed.
Installation
pip install cachau
Python 3.10+. Zero dependencies — NumPy, pandas, Polars, and Numba integrations activate automatically when those libraries are present, without ever importing them.
Quick start
from cachau import cache
@cache(persist=True, max_memory="500MB")
def slow_square(n):
print("computing...")
return n * n
slow_square(12) # computing... → 144
slow_square(12) # → 144 (HIT — and it survives a restart)
slow_square.cache.stats().hit_rate # 0.5
print(slow_square.cache.explain(12))
# HIT
# Reason: found
# Namespace: __main__.slow_square
# Created: 2026-07-19 18:02:33 UTC
# Age: 0s
# Size: 28 B
Why not just functools.lru_cache / joblib / diskcache?
Plenty of libraries offer TTL, persistence, or LRU. None of them combine what data workloads actually need:
| Problem | Cachau's answer |
|---|---|
| Hashing a 2 GB DataFrame just to build a key | Native hashing for NumPy, pandas, and Polars (dtype + schema + content; layout-canonicalized) — plus explicit key= / ignore= escape hatches |
| Stale results after you edit the function | Code-fingerprint invalidation by default: change x * 2 to x * 3 — or a closure capture, or a Numba compile flag — and the old result dies |
| N threads recomputing the same missing key | Same-key single-flight: one computation, everyone else reuses it; independent keys never serialize |
| Caches that eat all your RAM or disk | First-class max_memory bounds with predictable LRU eviction; oversized results are returned but never cached |
| Notebook restarts throwing work away | persist=True — atomic, versioned, corruption-safe on-disk format that survives restarts |
| "Why was that a miss?!" | func.cache.explain(...) tells you exactly what happened and why — as pure observation |
| Numba treated as an afterthought | First-class support at the dispatcher boundary — fastmath/parallel/locals=-aware identity, honest per-specialization cold/warm JIT metrics |
| Results that outlive the data they came from | depends_on=["data.csv", cachau.env("MODE"), cachau.package("numpy")] — a result dies when its file, env var, package version, or custom token changes |
A taste of the API
The common case is one decorator, zero configuration:
@cache
def load_dataset(path):
return pd.read_parquet(path)
Configuration is declarative and progressive — no backend objects, no config files:
@cache(ttl="1h")
def build_features(df, config):
...
@cache(persist=True)
def train_embedding(dataset_hash, params):
...
@cache(max_memory="2GB")
def expensive_simulation(seed, params):
...
@cache(ignore=["logger", "progress_callback"])
def run(data, logger=None, progress_callback=None):
...
@cache(key=lambda dataset, version: version)
def process(dataset, version):
...
Big immutable array arguments: token, not content-hash. By default every lookup hashes every argument — for a large array that is immutable for the whole run (a lookup table, a canonical index map), that pays the same hashing cost on every single call, and profile() will name it as the dominant hit cost. cachau.array_token(arr) hashes the content once per live object and reuses the digest inside an explicit key=:
@cache(key=lambda table, n: (cachau.array_token(table), n))
def lookup(table, n):
...
Measured on a 134 459-element float64 array: content-hash HIT 600 µs vs token-key HIT 3.4 µs. The memo is identity-safe (weakref-checked, so a recycled id() can never resurrect a dead object's digest); the caller's side of the contract is that the array is not mutated in place while the token is in use.
Declare external inputs a result depends on, and Cachau invalidates when they change:
@cache(depends_on=[
"data/train.parquet", # a file — content hash by default
cachau.file("big.bin", on="mtime"), # or cheap mtime+size, opt-in
cachau.env("PIPELINE_MODE"), # an environment variable
cachau.package("scikit-learn"), # an installed package version
cachau.token(lambda: db.schema_version()), # any custom token
cachau.code(normalize), # a helper function's implementation
])
def build_features(...):
...
A changed dependency is a dependency_changed miss: the stale entry is dropped and the function recomputes. The fingerprints ride along as small metadata in each stored entry, not in the key — so a changed dependency overwrites the same entry, and the miss is attributed to the dependency instead of vanishing as a key-not-found. explain() names exactly which one changed.
Validation, not versioning. depends_on keeps ONE entry per argument-key and re-validates it: flip a token v1 → v2 → v1 and each flip is a fresh dependency_changed miss — the v1 result is not waiting for you, it was overwritten. When results must be retained per version (keep the v1 AND the v2 solve), the version belongs in the key: pass it as an argument, fold it into key=, or give each version its own namespace. A field report got this wrong on the first try, which makes it worth stating plainly: depends_on answers "is this entry still valid?", never "which version do you want?".
Files default to a content hash (correctness first — a same-size replacement that preserves the modification time still invalidates); on="mtime" opts into a cheaper mtime+size check for large files where hashing every lookup is the bottleneck. A declared dependency is assumed stable for the duration of one call — if it can change while the function runs, pass it as an argument so it enters the key. That contract is defended, not just assumed: a dependency file being rewritten during the hash produces a torn fingerprint that matches neither the old nor the new content (a conservative recompute, never a false HIT), and a dependency observed to change between call start and commit means the result is returned but refused for caching (dependency_race_skips) — no stable fingerprint describes what the function actually read. What the two boundary snapshots cannot see is a change that reverts within a single call (A→B→A); that case stays on the caller's side of the contract.
What the code fingerprint does not see. Changing a cached function's own implementation invalidates automatically, and so does changing a value or function it captures by closure. But a module-level helper called by global lookup is outside the fingerprint: edit the helper, restart, and the cache serves results computed with the old code. cachau.code(helper) closes that gap — it fingerprints the helper's implementation (bytecode, constants, closures, same digest as the cached function itself) and invalidates as dependency_changed when it differs. profile() warns about same-package helpers that are called by global lookup and left undeclared.
verify= — the safety net for what nothing fingerprints. @cache(verify=0.05) recomputes that fraction of HITs anyway and compares the fresh result against the cached value (by content — ndarrays and DataFrames included). A mismatch is loud: a CacheVerificationWarning, its own miss reason (miss_verification_failed in stats(), alongside verifications/verification_failures totals), and the fresh value both replaces the entry and is what the caller gets. It catches the two failures no fingerprint can: a transitive code change that slipped through undeclared, and a nondeterministic function drifting from its cached past. The sampled recompute is the price of the check — verify=1.0 checks every hit, useful in tests and pipelines where trust matters more than speed.
Every cached function carries its own control surface:
build_features.cache.stats() # hits, misses, hit rate, miss reasons, bytes,
# evictions, compute time, estimated time saved,
# cold-JIT time — as an immutable snapshot
build_features.cache.clear()
build_features.cache.invalidate(df, config)
build_features.cache.inspect() # browse the cached entries
build_features.cache.explain(df, config) # pure observation, never recomputes
build_features.cache.profile(df, config) # measures: is caching worth it?
explain() — transparency on demand
MISS
Reason: expired
Namespace: features.build_features
Created: 2026-07-19 14:03:11 UTC
Expired: 3m 2s ago (at 2026-07-19 15:03:11 UTC)
Size: 1.2 MB
With depends_on=, a changed dependency reports as its own reason and shows exactly which one changed, before and after:
MISS
Reason: dependency_changed
Changed dep: env:PIPELINE_MODE (v:fast -> v:thorough)
Namespace: features.build_features
Created: 2026-07-19 14:03:11 UTC
Size: 1.2 MB
An entry the LRU budget dropped reports evicted rather than not_found, so you can tell "never cached" from "cached, then pushed out".
A failed write never loses the computed result — but it also leaves nothing behind to find, so a broken store (unwritable persist= directory, unpicklable results) would otherwise look exactly like a cold cache. When a function has already recorded write failures, a not_found miss says so:
MISS
Reason: not_found
Warning: 2 cache writes failed — this may be a broken store, not a cold cache
Namespace: features.build_features
The count is also on the explanation as write_errors (and, function-wide, in stats()).
inspect() — browse what's cached
inspect() lists the entries a function currently holds — newest first, read from entry headers without deserializing any values, so it stays cheap over a large persistent cache:
3 cached entries for features.build_features (4.6 MB)
a1b2c3d4e5f60718 1.2 MB age 3m ttl 57m deps env:PIPELINE_MODE
9f8e7d6c5b4a3021 2.1 MB age 11m ttl 49m deps env:PIPELINE_MODE
0011223344556677 1.3 MB age 2h EXPIRED deps env:PIPELINE_MODE
The result is a plain read-only sequence of CacheEntryView (indexable, iterable), each with age_seconds, ttl_remaining_seconds, is_expired, size_bytes, and dependency_fingerprints — natural to poke at in a notebook cell.
profile() — is caching even worth it?
profile() measures both sides of the cache-economics inequality (T_key + T_lookup + T_deserialize < T_recompute) for one concrete call — running the function, warmed up, so JIT compile time is never counted — and tells you which side wins and why:
Cache economics: features.aggregate
Warm recompute: 3.9 ms
Key generation: 16.0 ms
Cache read: 0 us
------------------------------
Cache hit total: 16.0 ms
Caching is slower than recompute by 4.1x.
Primary hit cost: hashing ndarray[float64, 30.5 MB]
Recommendation: Provide an explicit stable key= (e.g. a dataset version)
so the payload isn't hashed on every lookup - that is the
whole cost here.
Here hashing a 30-MB array to build the key costs more than just recomputing the result, so the cache makes things worse — and profile() says so, names the culprit, and points at the fix. Unlike explain(), it runs the function (it must, to measure recompute cost); it doesn't touch stats() and restores cache state afterward. Cachau doesn't just cache — it tells you when caching is a bad decision.
cachau.testing — certify it, don't trust it
Observability answers questions; a pipeline that must trust its cache should also certify it, in its own test suite:
from cachau.testing import assert_cache_faithful, assert_invalidates
assert_cache_faithful(build_features, df, config) # HIT == fresh compute, content-exact
assert_invalidates(build_features, lambda: bump_schema_version(), df, config,
reason="dependency_changed")
assert_cache_faithful fails when a HIT would serve anything other than a fresh compute — a nondeterministic function, an undeclared input, a mutated stored value. assert_invalidates fails when the perturbation does not turn the entry into a MISS (optionally checking the reason): an invalidation check that cannot fail certifies nothing. Both prime a cold entry, fail loudly if nothing caches at all, and read the cached value straight from the backend so verify= sampling cannot mask a divergence. They execute the function — they belong in tests, not on hot paths.
The persistent cache directory is a trust boundary
Persisted values are serialized with pickle, so reading an entry deserializes whatever is on disk. Treat the cache directory the way you treat an importable Python file:
- Keep it private to the user or service running the cache — the default
.cachau/under your project is fine;/tmp, a world-writable share, or a volume mounted into a less-trusted container is not. - Never point
persist=at a directory another user or process can write to. Writing there is equivalent to executing code inside your process on the next read. - Never ship or download a prepopulated cache directory as if it were data.
Cachau treats damaged entries as a MISS (bad version, corrupt metadata, undecodable payload — the file is dropped and the value recomputed), but that is corruption handling, not a defense against a hostile writer.
One honesty note on persisted keys: pandas and Polars data identity uses those libraries' own hashers, which do not promise stability across their versions — upgrading pandas or polars can re-miss previously persisted entries. That is a conservative recompute, never a false HIT.
Multiple processes sharing one cache directory is always safe for correctness: writes are atomic (per-writer temp file, os.replace, directory fsync), so concurrent workers can never corrupt the store or read a torn entry. By default they are not deduplicated — N workers missing the same expensive key all compute it and the last write wins. coalesce="processes" opts into cross-process single-flight via advisory lock files: workers missing one key elect a single computer (O_CREAT|O_EXCL, atomic on POSIX and Windows) and the rest poll for its commit. The elected holder heartbeats its lock, so "stale" means the holder stopped beating (it crashed — recovered within a few seconds), never that the compute is slow: a healthy holder is never preempted no matter how long it runs, while a wedged-but-alive one keeps beating and waiters simply run out their own bounded deadline — derived from the function's observed compute time — and compute anyway. Every degradation is "compute redundantly", exactly the uncoordinated behavior; nothing can hang. stats() reports process_coalesced_hits, process_flight_timeouts, and stale_locks_broken. Validated in-repo with a real multi-process cold-burst test; large-scale contention validation is tracked in #35.
First-class Numba support
from numba import njit
from cachau import cache
@cache(ttl="1h", max_memory="4GB", persist=True)
@njit
def simulate(values, iterations):
...
Cachau caches results at the Python → dispatcher boundary (@cache goes below @njit); Numba's cache=True caches machine code. Use Cachau's result cache instead of cache=True, not on top of it: persist= already survives restarts, and Numba's on-disk .nbi/.nbc cache has produced hard-to-diagnose cross-process crashes on multi-process Windows farms — stacking the two keeps that hazard while adding nothing the result cache doesn't provide. Decorating a cache=True dispatcher emits a MachineCodeCacheWarning. Dispatcher identity covers the Python function, closure captures, and semantically relevant compile options (fastmath, parallel, boundscheck, error_model, locals= type forcing) — changing any of them invalidates stale results. Metrics are honest about JIT: each specialization's first compile is reported as cold_compute_seconds and never counted as normal execution cost. Validated by a 26-test matrix.
Works with numba-utils
numba-utils' decorator aliases (njit_fast, njit_parallel, cached_njit, boundscheck) return real Numba dispatchers, so cachau composes with them out of the box — verified by an integration suite:
from numba_utils.decorators import njit_fast
from cachau import cache
@cache(persist=True)
@njit_fast # fastmath=True lands in the cache identity automatically
def kernel(values):
return values * 2.0
The options the aliases inject (fastmath, parallel) — and numba-utils' global configure() / NUMBA_UTILS_* overrides — all land in the dispatcher's compile options, so cachau fingerprints them: njit_fast and cached_njit with the same body never share an entry, and flipping a global override invalidates correctly. Its typed containers are Level B: as arguments they fail loudly (use key= / ignore=).
Design principles
- Correctness before hit rate. A false HIT is worse than a MISS. When in doubt, recompute.
- Safe by default. Exceptions aren't cached; serialization failure never loses your result; corruption degrades to a miss, never a mysterious error.
- Observable before clever. Every hit, miss, eviction, and skip has an inspectable reason code.
- No hidden magic. Automatic detection is conservative; explicitness beats unreliable cleverness.
- Bounded by design. Memory and disk limits are core features, not afterthoughts.
What Cachau is not
Not Redis, not a distributed cache, not a workflow engine, not an artifact registry, not an experiment tracker, not a joblib/Dask replacement. The scope stays narrow on purpose:
A pleasant, robust function cache for expensive Python data workloads.
Cache economics, measured
Caching has a cost — keying, lookup, deserialization — and cachau refuses to pretend otherwise. BENCHMARKS.md has the numbers (reproducible via benchmarks/): a memory HIT on a 50 ms function is a ~6,500× win with scalar args and ~12× with an 8 MB array arg — while caching a 200 ns function with an 80 MB argument is a ~200,000× loss. Measure, don't assume.
Documentation
- examples/ — four runnable scripts: quickstart with persistence, pandas workflows (
ignore=/key=), observability (miss reasons,explain()), and Numba workloads with honest JIT metrics - BENCHMARKS.md — measured keying costs, hit-vs-recompute economics, cold/warm JIT — with methodology
- VISION.md — why Cachau exists, positioning, and guiding maxims
- ROADMAP.md — phased plan from foundations to Numba Level B
- GUIDELINES.md — the full design & engineering spec (API, cache identity, TTL, eviction, persistence, invalidation, observability, concurrency, Numba, testing)
Contributing
The core engine is young and feedback is the most valuable contribution: try it on a real workload and open an issue with what surprised you. Bug reports with a failing test are gold. Before proposing features, read GUIDELINES.md, especially the feature acceptance bar: every addition must preserve correctness, explainability, and the narrow mission.
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file cachau-0.7.0.tar.gz.
File metadata
- Download URL: cachau-0.7.0.tar.gz
- Upload date:
- Size: 128.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
06915a5dcb658716a6fff64b1c0023b93529453898d6abc03c31a7df56c28e87
|
|
| MD5 |
a8622f20625579f62b7ffe25441064da
|
|
| BLAKE2b-256 |
a0c8c435eada9cd67c13e853e879f59689a8aa13f1d2cf91dafb4be61323ff0f
|
Provenance
The following attestation bundles were made for cachau-0.7.0.tar.gz:
Publisher:
release.yml on nicoseijas/Cachau
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cachau-0.7.0.tar.gz -
Subject digest:
06915a5dcb658716a6fff64b1c0023b93529453898d6abc03c31a7df56c28e87 - Sigstore transparency entry: 2442835068
- Sigstore integration time:
-
Permalink:
nicoseijas/Cachau@99770a48dfbb86e0b091615c7f6a49a2f40644e1 -
Branch / Tag:
refs/tags/v0.7.0 - Owner: https://github.com/nicoseijas
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@99770a48dfbb86e0b091615c7f6a49a2f40644e1 -
Trigger Event:
release
-
Statement type:
File details
Details for the file cachau-0.7.0-py3-none-any.whl.
File metadata
- Download URL: cachau-0.7.0-py3-none-any.whl
- Upload date:
- Size: 71.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f4b1d1e33e2fca9fe421cf791995b49ce9794393747050078a5a01bce313c335
|
|
| MD5 |
3bd8703189fab7fea67de647ff372f09
|
|
| BLAKE2b-256 |
56d4d6a9ef1976f14ee11be6f534181ed3bd4ba0614d22747162039c56d521b6
|
Provenance
The following attestation bundles were made for cachau-0.7.0-py3-none-any.whl:
Publisher:
release.yml on nicoseijas/Cachau
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cachau-0.7.0-py3-none-any.whl -
Subject digest:
f4b1d1e33e2fca9fe421cf791995b49ce9794393747050078a5a01bce313c335 - Sigstore transparency entry: 2442835675
- Sigstore integration time:
-
Permalink:
nicoseijas/Cachau@99770a48dfbb86e0b091615c7f6a49a2f40644e1 -
Branch / Tag:
refs/tags/v0.7.0 - Owner: https://github.com/nicoseijas
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@99770a48dfbb86e0b091615c7f6a49a2f40644e1 -
Trigger Event:
release
-
Statement type: