Indexing Compression Diff - dathere/qsv GitHub Wiki
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.
| 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 |
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,sliceare instantaneous -
frequency,split,stats,schemabecome multithreaded with an index (the ποΈ symbol in the README legend) -
luaugets random-access mode with an index (otherwise sequential only) -
sniffdraws a distributed sample with an index (otherwise it samples only the first--samplerows) β 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.idxExample: 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 500kExample: turn on auto-indexing for any file > 50 MB
export QSV_AUTOINDEX_SIZE=50000000
# Now qsv index runs automatically on big files when neededFixed 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 -1returned nothing β silent data loss -
samplewith a fractional size oversampled -
frequencymissed<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 hereSELECT * 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.
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.
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.
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 ./dataExample: strip everything associated with one file before packaging
qsv clean --all --force data.csvSee also: /docs/help/clean.md, Stats Cache & Caching β Cache-busting cheatsheet, index.
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.csvOutput 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.csvExample: handle differently-structured headers
qsv diff --no-headers-right \
--key 1 \
last.csv right_no_headers.csvExample: 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-emptySee also: /docs/help/diff.md, extdedup β for verifying primary-key uniqueness, blake3 β for fingerprint-level "did anything change at all" checks, Recipe: Diff & Audit.
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.csvSee also: /docs/help/blake3.md, pseudo β for reversible pseudonymization, diff β for row-level diff after a hash-level detection, BLAKE3 spec.
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.csvOn-disk hash-table dedup. Preserves input order (unlike dedup). See Aggregation & Statistics β extdedup.
qsv extdedup --select session_id huge_clickstream.csv > unique.csvStreaming 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.szAs of v21.1.0, compressed inputs just work β qsv detects and decompresses them automatically, with no pre-processing step:
-
Local
.zipinputs (and lookup tables) β the first tabular entry is extracted transparently. -
Compressed remote sources (
.gz/.zlib/.zst) for lookup tables andget/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.
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.
- Command Reference (index)
- Performance Tuning β when to index, how multithreading works
-
Environment Variables β
QSV_AUTOINDEX_SIZE, cache config - Stats Cache & Caching
-
Aggregation & Statistics β
extsort/extdedup -
Conversion & I/O β
snappydeep-dive - Cookbook β Diff & Audit
- Cookbook β Larger-than-RAM CSV