Indexing Compression Diff - dathere/qsv GitHub Wiki

Indexing, Compression & Diff

Tier: Intermediate Commands covered: index, diff, blake3, extsort, extdedup, snappy

Note

Per-command flag reference lives in /docs/help/. This page is the workflow layer β€” when to reach for each command and how they compose.

These are the performance primitives. None of them transform data β€” they make subsequent commands faster, smaller, or auditable. extsort / extdedup / snappy are also covered in their respective category pages; this page collects them for the "performance / ops" mindset.

Quick decision table

If you want to… Use Notes
Make count, sample, slice, stats, frequency, split, schema faster index One-time cost; speeds up 9 commands by 2-9Γ—
Auto-index files over a size threshold QSV_AUTOINDEX_SIZE env var See Environment Variables
Count rows on a file that may contain blank lines count 23.0.1 makes the indexed and unindexed counts agree β€” details
Get more accurate sniff type/date inference on a large file index, then sniff Distributed sample across the whole file, not just the first rows
Compute a cryptographic fingerprint of a file blake3 Cache keys, pipeline gating, integrity checks
Diff two CSVs ludicrously fast diff 1M Γ— 9 cols in <600 ms with a primary key
Sort a file > RAM extsort See Aggregation & Statistics
Dedup a file > RAM extdedup See Aggregation & Statistics
Stream-compress / decompress Snappy snappy Most qsv commands auto-handle .sz; this is the explicit interface
Remove qsv-generated cache files clean Dry-run by default; --stale for changed/orphaned caches only

index

Builds a <file>.idx sidecar that lets random-access commands (and multithreaded ones) skip parsing rows they don't need. The headline numbers from the README:

  • 14 seconds to index the 15 GB / 28 M-row NYC 311 dataset
  • After indexing: count, sample, slice are instantaneous
  • frequency, split, stats, schema become multithreaded with an index (the 🏎️ symbol in the README legend)
  • luau gets random-access mode with an index (otherwise sequential only)
  • sniff draws a distributed sample with an index (otherwise it samples only the first --sample rows) β€” see below

The index is automatically used by any command that benefits. If the source file changes, the index goes stale β€” re-run qsv index. If QSV_AUTOINDEX_SIZE is set, qsv auto-creates / refreshes the index for files larger than the threshold.

Note that sniff's gain is inference accuracy, not raw speed. With an index it samples across the whole file β€” the first & last 5 rows, 5 rows each around the 25th/50th/75th percentiles, and the rest random up to the --sample budget β€” instead of just the first --sample rows. This catches column types and dates that only appear late in a file (and likewise improves stats --infer-dates sniff). It falls back to the contiguous first-N sample when the file is not indexed.

Example: one-time index of the NYC 311 full export

qsv index nyc311-full.csv     # ~14s for 15 GB
ls -lh nyc311-full.csv*
# 15G  nyc311-full.csv
# 27M  nyc311-full.csv.idx

Example: index a CSV via shell, then enjoy the speedup

qsv index NYC_311_SR_2010-2020-sample-1M.csv
qsv count NYC_311_SR_2010-2020-sample-1M.csv      # instant
qsv slice -i 500000 NYC_311_SR_2010-2020-sample-1M.csv | qsv flatten  # parses 1 row, not 500k

Example: turn on auto-indexing for any file > 50 MB

export QSV_AUTOINDEX_SIZE=50000000
# Now qsv index runs automatically on big files when needed

Blank lines, the index, and the polars fast path

Fixed in 23.0.1. Before 23.0.1, count could return two different numbers for the same file depending on whether an index happened to exist. The asymmetry is in the two readers:

Reader A literal blank line becomes…
polars β€” the fast, unindexed count an all-null row, and is counted
qsv's csv reader, and the .idx built with it skipped entirely, and is not counted

polars 0.55 offers no skip-blank-lines option, so the unindexed count exceeded the indexed count by one per blank line.

Because util::count_rows funnels through the polars counter, that inflated number leaked into commands that never mention count:

  • slice --index -1 returned nothing β€” silent data loss
  • sample with a fractional size oversampled
  • frequency missed <ALL_UNIQUE> detection

Each of them flipped to correct behavior the moment an index existed, which is what made the bug so disorienting to hit.

23.0.1 detects files that may contain blank lines and falls back to the csv reader for those; all 13 count_rows consumers were audited and repaired. The detector is deliberately conservative β€” a blank-line byte pair inside a quoted multi-line field is a false positive β€” so the worst a false positive costs is the polars fast path on that one file, never a wrong count. (count --no-polars forces the same fallback explicitly.)

Warning

Only the counting half is fixed. polars-backed commands β€” sqlp, joinp and friends β€” still genuinely process the phantom all-null row that polars materializes for each blank line. On a 2-row file with one blank line in the middle:

qsv count data.csv            # 2  βœ… (and 2 with an index, too)
qsv sqlp data.csv 'SELECT COUNT(*) AS n FROM data'
# n
# 3                           ❌ the blank line is a row here

SELECT * likewise emits an extra all-empty record. If your file has literal blank lines, normalize it before handing it to SQL & Polars commands β€” qsv input data.csv > clean.csv reads through qsv's csv reader and drops them. This half has no fix and no issue filed yet.

Auto-indexing is race-free, atomic and permission-preserving

Fixed in 23.0.1. Config::index_files β€” the resolver every command uses to answer "is there a usable index for this input?" β€” carried a fast path keyed on a process-global AUTO_INDEXED flag that was set whenever any file got auto-indexed. The flag carried no path, so it also fired for inputs that had never been indexed, and the file open it guarded was unconditional: a missing sibling index became a hard error instead of a graceful "no index, read sequentially".

# on 22.0.1 and earlier
QSV_AUTOINDEX_SIZE=1 qsv stats --infer-dates f.zip
# io error: No such file or directory     (exit 1)
Before 23.0.1
A process-global flag could make one file's index "count" for another Gone β€” index resolution is strictly per-path
Concurrent stale-index rebuilds could race each other Rebuilds are serialized and atomic β€” a half-written .idx is never visible
An auto-created index got whatever permissions the temp-file helper chose Same permissions as one built by qsv index; a rebuild carries over the existing index's permissions
Each parallel worker rebuilt its own resolved Config Resolved Configs are shared with the workers

Note

New in 24.0.0: in 23.0.1 only auto-indexing was atomic β€” a failed qsv index, or the index stats/frequency/moarstats build internally, could leave a truncated .idx that later commands trusted, reporting a wrong row count at exit 0. All three index writers now build into a temp file and rename into place, and a malformed or truncated .idx from any source is ignored with a warning instead of being used (#4631).

Net effect: stale or wrong index reuse is gone, and an index whose permissions you deliberately tightened stays that way across rebuilds.

See also: /docs/help/index.md, Environment Variables β€” QSV_AUTOINDEX_SIZE, Performance Tuning, Stats Cache & Caching.

clean

New in 22.0.1. qsv scatters auto-regenerable caches next to your source files β€” a .idx index, a stats cache, a frequency cache. That's exactly what makes the "smart" commands fast, and exactly what you don't want to ship inside a data package. clean removes them.

clean is a dry-run by default. It lists what it would remove and the bytes it would reclaim, and deletes nothing until you pass --force:

# preview (default) β€” nothing is deleted
qsv clean

# actually delete
qsv clean --force

--dry-run/-n wins over --force if you pass both, so a preview flag can never be defeated by a stale --force left in a script.

What it will and won't touch

Only files that are verifiably qsv-generated are removed β€” never matched on filename alone:

Family Removed How it's verified
Index <file>.idx Must pass a csv-index size-multiple-of-8 magic check and have a sibling source file
Stats cache <stem>.stats.csv, .stats.weighted.csv, .stats.csv.json, .stats.csv.data.jsonl Anchored on the .stats.csv.json args sidecar β€” so your own hand-written report.stats.csv survives
Frequency cache <stem>.freq.csv.data.jsonl Validated by its first-line qsv_version/arg_input metadata

Outputs from schema, validate and moarstats are your results, not caches β€” they are never touched unless you name them explicitly with --schema, --validate, --moarstats, or --all.

Example: clear only what's gone stale, across a tree

--stale narrows the sweep to caches whose source file is newer than the cache, or whose source is gone entirely β€” the orphans that accumulate after a pipeline rerun:

qsv clean --stale --recursive --force ./data

Example: strip everything associated with one file before packaging

qsv clean --all --force data.csv

See also: /docs/help/clean.md, Stats Cache & Caching β†’ Cache-busting cheatsheet, index.

diff

The headline number: 1M Γ— 9 columns in under 600 ms. diff is not a line-level differ β€” it's a primary-key-aware differ. Each row in left/right is identified by one or more primary key columns; the differ classifies each row as added, removed, or modified.

Primary-key uniqueness is required. Use qsv extdedup --select keycol data.csv --no-output to check.

Example: weekly regulatory CSV diff

qsv diff --key id last_week.csv this_week.csv > delta.csv

Output columns include diffresult (Add / Remove / Modify), the primary key, and a side-by-side view of changed fields.

Example: focus the diff on only the columns that actually changed

qsv diff --key id --drop-equal-columns last_week.csv this_week.csv > delta.csv

--drop-equal-columns drops entire columns from the diff result that have no differences anywhere. A column is kept if it is a key column, if it differs in any modified row, or if it has a non-empty value in any added or deleted row β€” otherwise it is dropped. It pairs with --drop-equal-fields (which blanks out equal field values within modified rows): combine the two to keep only differing columns and, within them, only differing values.

Example: diff with different delimiters on each side

qsv diff --delimiter-left '\t' --delimiter-right ',' \
  --key 'BBL' \
  last.tsv this.csv

Example: handle differently-structured headers

qsv diff --no-headers-right \
  --key 1 \
  last.csv right_no_headers.csv

Example: gate a CI job on no-changes

qsv diff --key id reference.csv candidate.csv \
  | qsv count
# Exits with non-zero count = the diff is non-empty

See also: /docs/help/diff.md, extdedup β€” for verifying primary-key uniqueness, blake3 β€” for fingerprint-level "did anything change at all" checks, Recipe: Diff & Audit.

blake3

Multithreaded, mmap-backed BLAKE3 hashing. Functionally similar to b3sum but bundled into qsv. Supports keyed hashing, key derivation, variable-length output, and checksum verification.

The CSV-relevant uses are cache keys ("did the input change between runs?"), pipeline gating ("only run downstream if upstream changed"), and integrity checks ("is this file the same as the one signed off last week?").

Example: fingerprint a CSV for a pipeline-gating cache key

KEY=$(qsv blake3 input.csv | awk '{print $1}')
echo "Cache key: $KEY"

Example: verify a downloaded .zip matches an expected hash

echo "abc123...  downloaded.zip" | qsv blake3 --check -

Example: short hash for use as a column in a CSV

qsv blake3 -l 8 input.csv

-l N truncates the output to N bytes before hex encoding.

Example: keyed BLAKE3 for keyed-hash-based pseudonymization

echo -n 'my-32-byte-secret-key-............' \
  | qsv blake3 --keyed sensitive.csv

See also: /docs/help/blake3.md, pseudo β€” for reversible pseudonymization, diff β€” for row-level diff after a hash-level detection, BLAKE3 spec.

extsort (cross-reference)

External merge-sort for files > RAM. Multithreaded. See Aggregation & Statistics β†’ extsort for the full treatment.

qsv extsort --select 'Created Date' nyc311-full.csv > nyc311-by-date.csv

extdedup (cross-reference)

On-disk hash-table dedup. Preserves input order (unlike dedup). See Aggregation & Statistics β†’ extdedup.

qsv extdedup --select session_id huge_clickstream.csv > unique.csv

snappy (cross-reference)

Streaming Snappy compression. Multithreaded compression is 5-6Γ— faster than the auto-compression most qsv commands do on .sz output. See Conversion & I/O β†’ snappy.

qsv snappy compress nyc311-full.csv > nyc311-full.csv.sz

Transparent input decompression

As of v21.1.0, compressed inputs just work β€” qsv detects and decompresses them automatically, with no pre-processing step:

  • Local .zip inputs (and lookup tables) β€” the first tabular entry is extracted transparently.
  • Compressed remote sources (.gz / .zlib / .zst) for lookup tables and get / dc: ingests β€” streamed and decompressed on the fly.

.sz (Snappy) was already auto-handled (see snappy above); this extends the same convenience to zip and the common remote codecs.

Zip handling is now consistent across commands. The "extract all entries" path (cat, sqlp, to, validate, scoresql) and the reader-level "first tabular entry" path (every other command) now share a single zip module with one selection rule. Entries are returned tabular-first (CSV/TSV/TAB/SSV before other supported formats), so a single-input command and a Config-only command (stats, count, …) pick the same entry from a mixed multi-entry zip. A zip with no supported entry now errors clearly instead of failing downstream with a vague message.

Converted temp files

Both fixed in 23.0.1. A special-format input β€” .gz, .zip, .parquet, .jsonl β€” is not read in place; it is read through a converted temp file. Two bugs lived in that machinery.

The converted temp file is never auto-indexed. Every Config instance used to resolve its own temp path, so auto-indexing the temp produced an index only the parent's Config could ever find. With auto-indexing in play, qsv stats -E --cache-threshold -105 data.csv.gz wrote a stats file with headers and zero data rows, at exit code 0, after printing worker panics to stderr β€” a panicking pool worker only unwinds its own thread. The narrow fix was to refuse to auto-index the temp, which meant always running sequentially; stats then regained parallel processing for these inputs via a shared-Config pattern, as did split, search, searchset and pragmastat.

TEMP_FILE_DIR is now cleaned up in non-polars builds too. The cleanup in util::log_end was gated on the polars feature, while the directory is populated in every build β€” zip extraction needs only the non-optional zip crate, and the stdin spill temps in split, stats and partition land there as well. A non-polars build β€” qsvlite notably β€” therefore left a full decompressed copy of every .zip input on disk, once per invocation, until the OS reclaimed the temp dir, which on many systems means at reboot. If you have been running qsvlite over zipped inputs, your system temp dir is worth a look.

See Conversion & I/O β†’ special-format handling for the full format detection / error-surfacing behavior.

See also

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