Stats Cache and Caching - dathere/qsv GitHub Wiki

Stats Cache & Caching

Tier: Advanced

qsv caches at four levels. The flagship is the stats cache β€” feeding pre-computed statistics into "smart" downstream commands so they don't redo work. The other three are the frequency cache, the fetch / fetchpost / describegpt HTTP cache, and the Luau lookup-table cache.

This page explains the file layouts, who reads them, when they invalidate, and how to deliberately bust them.

The big idea

qsv leans heavily on sidecar files next to your CSV. Run stats once, and downstream commands get smarter and faster without re-reading the source file. The convention is:

File Producer Consumers
<csv>.idx qsv index count, sample, slice, stats, frequency, split, schema, luau
<csv>.stats.csv qsv stats Human-readable; not parsed by other commands
<csv>.stats.csv.data.jsonl qsv stats --stats-jsonl The actual cache used by frequency, schema, validate, pragmastat, pivotp, sqlp scoresql, tojsonl, sample, describegpt
<csv>.stats.csv.json qsv stats stats itself β€” records the arguments and qsv version used to build the cache; checked on the next run to decide whether the cache is still valid or must be recomputed
<csv>.freq.csv.data.jsonl qsv frequency --frequency-jsonl frequency itself (cache hits), describegpt, scoresql, viz
<csv>.schema.json qsv schema validate
<csv>.pschema.json qsv schema --polars sqlp, joinp, pivotp

The stats cache

Layout

my_file.csv                        β€” source
my_file.csv.idx                    β€” row-offset index
my_file.stats.csv                  β€” human-readable stats CSV
my_file.stats.csv.data.jsonl       β€” machine-readable JSONL (the actual cache)
my_file.stats.csv.json             β€” cache signature (args + qsv version) used for invalidation

Producing it

The minimum to be useful:

qsv stats --stats-jsonl my_file.csv

The full version for max downstream benefit:

qsv stats --everything --infer-dates --infer-boolean --cardinality --stats-jsonl my_file.csv

Or β€” set an env var to let qsv create it automatically the next time a smart command needs it:

export QSV_STATSCACHE_MODE=force

Three modes:

  • auto (default) β€” use the cache if it exists and is fresh
  • force β€” auto-create the cache on first smart-command invocation
  • none β€” ignore the cache entirely

Who reads it (the "smart" commands, πŸͺ„)

  • frequency β€” short-circuits all-unique columns (rowcount == cardinality) using a sentinel ALL_UNIQUE value; needs the cache to handle ID columns without OOM
  • schema β€” skips redundant type inference; faster generation
  • validate β€” uses inferred ranges, formats, and patterns from the cache
  • pragmastat β€” date-aware mode requires the cache (stats -E --infer-dates --stats-jsonl)
  • pivotp β€” smart aggregation auto-selection based on data type
  • tojsonl β€” smart JSON type inference (string/number/bool/null per column)
  • sample β€” extra checks for --systematic, --weighted, --cluster modes
  • sqlp scoresql β€” query plan analysis (filter selectivity, join cardinality)
  • describegpt β€” deterministic statistical context for the LLM

Invalidation

The cache is timestamp-keyed against the source CSV. If the source file's mtime changes, the cache is considered stale and silently regenerated (unless QSV_STATSCACHE_MODE=none).

Important

Two validators, two files β€” they are not the same check. stats itself also compares the arguments and the qsv version recorded in the <csv>.stats.csv.json signature sidecar before reusing its own .stats.csv. Downstream consumers read <csv>.stats.csv.data.jsonl and judge it by mtime and parsing options only β€” no version check. So after a qsv upgrade, stats recomputes while a .data.jsonl written by the older version keeps being served to schema, frequency, tojsonl and viz smart until the source CSV's mtime changes. On upgrade, regenerate deliberately: qsv stats --force --stats-jsonl <file>.

New in 24.0.0: moarstats and pragmastat now also treat the baseline as stale when its .stats.csv.json sidecar was written by a different qsv version, so their appended columns are recomputed after an upgrade (#4655). --flexible is part of the parsing-options key: a cache built from ragged data under --flexible is refused to a strict run, while a strict cache can still serve a --flexible run (#4616). And the .stats.csv / .stats.csv.data.jsonl pair is never more readable than its source CSV β€” a 0600 input gets 0600 caches, a world-readable one gets reusable ones (#4622). For a symlinked input, stats --stats-jsonl and moarstats now write the cache beside the link's canonical target, where readers look for it β€” before, viz smart --smarter re-ran plain stats on the link and silently dropped the moarstats enrichment (#4697).

Deliberate bust:

# Delete the whole set, including the .json signature sidecar, so a signature
# never outlives the statistics it describes.
rm my_file.stats.csv my_file.stats.csv.data.jsonl my_file.stats.csv.json
# Or force a rerun:
qsv stats --force --stats-jsonl my_file.csv

Stats cache fixes in 23.0.1

Important

New in 23.0.1. Three stats-cache bugs are fixed. All three were silent β€” no error, exit code 0 β€” and every stats-cache consumer (schema, frequency, viz smart, describegpt, tojsonl, validate, pragmastat, …) inherited the results. Regenerate any stats cache built by 22.0.1 or earlier with qsv stats --force --stats-jsonl.

1. An args-changed recompute left a stale sidecar behind (#4439). Deleting <FILESTEM>.stats.csv on an args change did not delete its <FILESTEM>.stats.csv.json signature sidecar, so the next run validated against the stale sidecar and trusted it. The reproducer is three ordinary runs:

qsv stats f.csv -E -c 1      # writes the full cache
qsv stats f.csv --typesonly  # args changed: CSV deleted, sidecar survives
qsv stats f.csv -E           # served the --typesonly output as though it were --everything

schema, frequency, tojsonl and viz smart all inherited that wrong answer. The sidecar is now deleted with its CSV, as a unit.

2. A CSV with an empty column name broke every stats-cache consumer (#4410, #4412). The CSV→JSON conversion behind the cache omitted any key whose cell was empty, so a column with an empty name lost its field key — and field was the only non-optional string in the cache record, so the cache then failed to deserialize with missing field `field` . Reported against tojsonl, but schema and frequency failed identically. field is now always emitted, keyed by name rather than position, so a future reordering of the stats columns cannot silently reintroduce it. field is also now #[serde(default)], so the poisoned caches already written to disk — which still pass mtime validation after an upgrade — keep loading rather than hard-failing.

3. Date renderings are no longer coerced to 0.0 in the cache (#4441). On a Date/DateTime column, cells like median are type-dependent RFC3339 renderings, not numbers, but were typed as floats for the JSON conversion β€” which coerced them to 0.0. They are now carried losslessly as strings; a consumer that wants a number parses it, using the row's type to know that's meaningful. Pre-fix caches stay on disk and still pass mtime validation, so the fabricated zeros a pre-fix cache carries for a date row are dropped at load time β€” only a rendering that reads as a number on a date row is dropped, so this is a no-op for any cache written after the fix.

The frequency cache

Layout

my_file.freq.csv.data.jsonl        β€” frequency distribution per column

Producing it

qsv frequency --frequency-jsonl my_file.csv

Stored as JSONL with metadata per column. Two sentinel values appear for memory-bounded columns:

  • ALL_UNIQUE β€” when rowcount == cardinality (typical of ID columns)
  • HIGH_CARDINALITY β€” when cardinality exceeds the smaller of QSV_FREQ_HIGH_CARD_THRESHOLD (default 100) and QSV_FREQ_HIGH_CARD_PCT (default 90%) of rowcount

Who reads it

  • describegpt β€” frequency context for LLM data dictionaries
  • sqlp scoresql β€” filter-selectivity scoring

Invalidation

Same as the stats cache: stale if source CSV's mtime is newer. The cache is NOT used when --ignore-case, --no-trim, or --weight are active (those change how values are bucketed). Use --force to regenerate.

Note

New in 24.0.0: the frequency cache also checks the qsv version that wrote it (#4618). A plain frequency run that finds a cache from another version, for the same column selection, recomputes and rewrites it in place β€” it refreshes an existing cache but never creates one. viz rejects a version-stale cache without rewriting it and computes its own. --flexible is part of the key too (see above).

The Polars schema cache

Layout

my_file.pschema.json               β€” Polars schema (column types + Polars dtypes)

Producing it

qsv schema --polars my_file.csv

Who reads it

Every Polars-powered command: sqlp, joinp, pivotp (and lens, count, color, prompt, scoresql when reading Polars-supported formats).

Pre-computed Polars dtypes mean Polars skips the inference scan it would otherwise do. Big speedup for repeated queries against the same file. Generate it once, check it into git, every Polars command becomes faster.

Invalidation

If the file's columns change, regenerate:

qsv schema --polars --force my_file.csv

Breaking: on-disk cache backend sled β†’ redb (22.0.1)

Warning

22.0.1 migrated the cached crate 2.0.2 β†’ 3.0.0, swapping the on-disk cache backend from sled to redb. Two things change for you β€” one is a one-time cost, the other silently inverts a setting you may already have set.

Affected caches: fetch/fetchpost (--disk-cache & Redis), geocode (the OpenCage result cache), apply summarize, and describegpt.

1. Existing on-disk caches are invalidated (one-time)

redb cannot read sled's format, so entries are recomputed on first run after upgrading. Expect one slow run, then normal speed. Redis caches self-heal; nothing to do.

2. ⚠️ A TTL of 0 now means the opposite of what it did

Setting Under sled (≀ 21.1.0) Under redb (22.0.1+)
TTL of 0 Every entry immediately stale (effectively no caching) Cache indefinitely β€” time-based expiration disabled

This applies to QSV_DISKCACHE_TTL_SECS, QSV_REDIS_TTL_SECS, geocode --cache-ttl, and apply summarize. cached v3 rejects a zero TTL at build time, so 0 had to be given a meaning β€” and it got the opposite one.

Caution

If you used TTL=0 as a way to disable caching, it now does the reverse and caches forever. Use --no-cache (geocode/apply), or simply omit the cache-enable flags, to actually turn caching off.

Smaller consequences

  • geocode cache-info reports the entry count as "unknown", not 0. RedbCache::cache_size() is Ok(None) by design, and reporting 0 would be actively misleading.
  • The recomputable external-API caches (fetch/fetchpost disk cache, geocode OpenCage lookups) keep sled's non-durable behavior via .durable(false), avoiding an fsync per write on the per-row hot paths.

The HTTP cache (fetch / fetchpost / describegpt)

Four cache backends, chosen via CLI flag:

  1. In-memory LRU (default) β€” non-persistent, 2M entries, lost on process exit. Tune with --mem-cache-size.
  2. Disk (--disk-cache) β€” stored at ~/.qsv-cache/fetch/ (or --disk-cache-dir). TTL: 28 days (QSV_DISKCACHE_TTL_SECS).
  3. Redis (--redis-cache) β€” shared across machines. Default redis://127.0.0.1:6379/1. TTL: 28 days (QSV_REDIS_TTL_SECS).
  4. None (--no-cache) β€” for live data.

fetch keys

Cache keys are the URL + GET parameters. Two calls to the same URL return the cached value (within TTL).

fetchpost keys

Cache keys include the POST body β€” so two identical posts share a cache slot.

describegpt keys

Cache keys include the prompt and the data context β€” so identical questions against unchanged data are cached.

Invalidation

By TTL or by manual delete:

# Disk cache
rm -rf ~/.qsv-cache/fetch/

# Redis cache (database 1 for fetch, 2 for fetchpost, 3 for describegpt)
redis-cli -n 1 FLUSHDB

Or set QSV_DISKCACHE_TTL_REFRESH / QSV_REDIS_TTL_REFRESH to refresh TTL on cache hit (keeps hot URLs cached indefinitely).

See HTTP & Web and Recipe: Fetch & Cache.

The Luau lookup-table cache

When a Luau script calls qsv_register_lookup("table", "URL_OR_PATH"):

  • Local files are read directly.
  • Remote URLs (http://, https://, dathere://, ckan://) are downloaded and cached in $QSV_CACHE_DIR (default ~/.qsv-cache/).

The cache key includes the URL. Subsequent runs reuse the cached copy until you delete it.

See Lookup Tables and Scripting (Luau / Python) β†’ luau.

Cache-busting cheatsheet

Cache Bust by
Stats / frequency / Polars schema / JSON schema qsv <cmd> --force <file> or rm <file>.stats.csv* etc.
Source-CSV-driven (auto) Modify the source CSV (mtime change auto-invalidates)
Index rm <file>.idx or qsv index --force <file>
HTTP disk cache rm -rf ~/.qsv-cache/fetch/
HTTP Redis cache redis-cli -n <db> FLUSHDB
Lookup-table cache rm -rf $QSV_CACHE_DIR/lookup-tables/
All qsv-generated caches beside a file/tree qsv clean --force (dry-run by default; --stale for changed/orphaned only)

CI / pipeline gotchas

  • Commit .stats.csv.data.jsonl and .pschema.json to git for reference datasets you query repeatedly. Tiny files, huge speedup.
  • Don't commit .idx β€” they're regenerated cheaply and platform-specific.
  • In CI, prefer QSV_STATSCACHE_MODE=force β€” eliminates "did I remember to run stats first?" mistakes.
  • For multi-stage Make-based pipelines, list the cache files as explicit dependencies so make invalidates them when the source changes.

See also

⚠️ **GitHub.com Fallback** ⚠️