Conversion and IO - dathere/qsv GitHub Wiki

Conversion & I/O

Tier: Intermediate Commands covered: excel, readstat, writestat, to, json, jsonl, tojsonl, snappy, geoconvert, prompt, clipboard

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 commands bridge between CSV and other formats: Excel/ODS, the SAS/Stata/SPSS statistical formats (both directions), JSON (array & JSONL), Parquet, SQLite, PostgreSQL, Snappy-compressed, GeoJSON/SHP, plus the OS clipboard and file picker dialogs.

The clipboard command is also covered in Selection & Inspection β†’ clipboard. The geoconvert command is also covered in Geospatial β†’ geoconvert. This page focuses on the I/O angle.

Quick decision table

If you want to… Use Notes
Convert Excel / ODS sheet β†’ CSV excel Pick sheet by name (case-insensitive) or index
Convert SAS / Stata / SPSS file β†’ CSV readstat .sas7bdat, .xpt/.xpt5/.xpt8, .dta, .sav/.zsav/.por (new in 23.0.1); --dictionary also writes a JSON Schema data dictionary (24.0.0)
Write a CSV as SPSS / Stata / SAS transport writestat .sav/.por, .dta, .xpt β€” restores labels & missing values from a readstat --dictionary (new in 24.0.0)
CSV β†’ Parquet / SQLite / Postgres / Excel / ODS / Data Package to Parquet supports zstd / gzip / snappy / lz4raw
JSON array β†’ CSV json Use --jaq for nested / awkward JSON
JSONL β†’ CSV jsonl First line determines headers
CSV β†’ JSONL with smart type inference tojsonl Uses the stats cache for type inference
Streaming Snappy compress / decompress snappy Most qsv commands auto-handle .sz
CSV ↔ GeoJSON / SHP / SVG geoconvert See Geospatial
OS file picker for input or output prompt Pairs well with clipboard and lens
Read/write the OS clipboard clipboard See Selection & Inspection

excel

Export an Excel (.xls, .xlsx, .xlsm, .xlsb) or LibreOffice (.ods) sheet to CSV. The first non-empty row is treated as the header.

Example: export the first sheet of a workbook

qsv excel input.xlsx --output output.csv

Example: export by sheet name (case-insensitive)

qsv excel --sheet 'Sheet 3' input.xlsx > sheet3.csv

Example: export a sheet by negative index (last sheet)

qsv excel --sheet -1 input.xlsx > last_sheet.csv

Example: export every sheet to its own CSV (one shell loop)

for i in $(qsv excel --metadata input.xlsx | qsv select sheet_name | qsv behead); do
  qsv excel --sheet "$i" input.xlsx --output "${i}.csv"
done

The MCP server and Claude Cowork plugin auto-convert Excel inputs via this command β€” see MCP Server.

See also: /docs/help/excel.md, to xlsx β€” the inverse (CSV β†’ Excel), sniff.

readstat

New in 23.0.1. Convert SAS, Stata & SPSS binary files to CSV.

Decades of public-sector and academic data β€” health surveys, labor statistics, longitudinal panels, replication archives β€” are published only in the proprietary formats of the big statistical packages. readstat opens them with no SAS/Stata/SPSS licence required, and without the ReadStat C library or pyreadstat: it is powered by polars-readstat-rs, which is pure Rust.

Warning

EXPERIMENTAL. The readers this command is built on are young. Spot-check the output against your source files before relying on a conversion, and please report anything that looks wrong.

Supported inputs

Package Extensions Memory
SAS .sas7bdat streams β€” constant memory
SAS transport .xpt, .xpt5, .xpt8 streams β€” constant memory
Stata .dta streams β€” constant memory
SPSS .sav, .zsav streams β€” constant memory
SPSS portable .por 🀯 read whole β€” see the warning below

The format is picked from the file extension, so stdin is not supported β€” readstat needs a real path. Piping out of it works normally. Passing a .sas7bcat (a SAS format catalog, not a dataset) errors with a pointer to the .sas7bdat file instead.

Flags

Flag Type Description Default
--metadata <fmt> string Dump variable metadata instead of the data. Valid values: none, csv, json, pretty-json. none
--value-labels flag Decode coded values to their label strings (e.g. 1 becomes "Male") instead of writing the underlying codes. For SAS .sas7bdat files (24.0.0+), the labels come from the format catalog named by --sas7bcat, else <name>.sas7bcat or formats.sas7bcat beside the data file. Not supported for .xpt. (23.0.1 was Stata & SPSS only.)
--sas7bcat <file> string 24.0.0+. Names the SAS format catalog holding a .sas7bdat file's value labels, for --value-labels, --sentinels-as label or --metadata. On its own it is refused for the data.
--dictionary <file> string 24.0.0+. Also write a JSON Schema data dictionary of the CSV, built from the file's own metadata β€” see below.
--compress-numeric flag 24.0.0+. Write float variables that only ever hold whole numbers as integers (3.0 becomes 3). Decided per variable over the rows written, so the file is read twice.
--sentinels-as <mode> string 24.0.0+. Keep user-defined missing values ("sentinels") instead of writing empty cells: value (the code, e.g. .A or 99), label (its value label), or none (empty cells, and skip the dropped-sentinel check). Each eligible variable gets a <name>_null column.
--sentinels-embedded flag 24.0.0+. Write each sentinel into its variable's own column instead of a <name>_null column. Requires --sentinels-as.
--sentinels-columns <list> string 24.0.0+. Only keep sentinels for these variables. Requires --sentinels-as.
--select <cols> string 24.0.0+. Read only these variables, in this order, using qsv's select syntax (names, indices, q1-q20, /regex/, ! to exclude). Skipped variables are never decoded. Also narrows --metadata.
--offset <n> / --limit <n> integer 24.0.0+. Read a window of rows. Stata, uncompressed SPSS & SAS transport files jump straight to the offset. 0 /
--sample <n> / --seed <n> integer 24.0.0+. Write a random sample of n rows from that window, in file order; --seed makes it reproducible.
-j, --jobs <arg> integer Number of reader threads. Raising it speeds up large uncompressed files at the cost of memory, as out-of-order chunks have to be buffered to keep the rows in source order. Row order is preserved either way. 1
-b, --batch <size> integer Number of rows to read into memory at a time. Does not apply to SPSS portable (.por) files β€” they have no chunked reader upstream, so they are read whole & memory scales with the file. Since 24.0.0, nor to SPSS .sav/.zsav files while their sentinels are tracked (see below), which also reads them whole. 50000
-o, --output <file> string Write output to <file> instead of stdout.
-d, --delimiter <arg> string The delimiter to use when writing CSV data. Must be a single character. ,

Example: convert a SAS dataset, then profile it

qsv readstat nhanes.sas7bdat --output nhanes.csv
qsv stats nhanes.csv | qsv table

Example: a coded SPSS survey β€” codes by default, labels on request

# default: the underlying codes survive, so `frequency`/`stats` see real numbers
qsv readstat survey.sav | qsv frequency --select sex

# --value-labels decodes them to "Male"/"Female" instead (Stata & SPSS; SAS too, 24.0.0+)
qsv readstat --value-labels survey.sav --output survey_labelled.csv

Example: dump the variable dictionary as a browsable table

qsv readstat --metadata csv panel.dta | qsv table

Example: straight into a pipeline, no temp file

qsv readstat data.sas7bdat | qsv stats --everything

Example: peek at part of a large file (24.0.0+)

qsv readstat --select id,age,income --limit 1000 survey.sav
qsv readstat --sample 500 --seed 42 data.sas7bdat

Codes vs. labels β€” why codes are the default

These formats store a coded value (1) alongside a value label ("Male") held in a separate dictionary. readstat writes the code, not the label. That is deliberate, and lossless:

  • stats, schema, frequency and describegpt then see the numbers the file actually stores, so type inference is correct and the coding survives a round trip.
  • The labels are not lost β€” --metadata dumps the full value-label map for every variable.

Pass --value-labels when you want the human-readable strings instead. On 23.0.1 it was Stata & SPSS only and errored on a SAS file.

New in 24.0.0: SAS labels are supported (#4626). --value-labels on a .sas7bdat file uses its format catalog: <name>.sas7bcat, then formats.sas7bcat, beside the data file. --sas7bcat <file> names one explicitly. If no catalog is found, the command fails with a hint instead of silently writing codes. --metadata then includes each labeled SAS variable's value_labels. --sentinels-as label uses the same catalog to label only the sentinels .A-.Z & ._, leaving the values as codes unless --value-labels is also given. --metadata lists sentinel labels as MISSING_A, etc.

qsv readstat --sas7bcat formats.sas7bcat survey.sas7bdat --output survey_labelled.csv

Note

Numeric variables arrive as floats. These formats store every numeric as a double, so an integer-looking column converts with a decimal point β€” a YEAR of 1948 becomes 1948.0, and a coded sex of 1 becomes 1.0. QSV_POLARS_FLOAT_PRECISION controls how many decimal places are written (see Environment Variables).

New in 24.0.0: --compress-numeric writes such variables as integers (1948, 1) when every value in them is a whole number (#4661).

Variable metadata

--metadata csv|json|pretty-json dumps what these files carry about their variables β€” variable labels, value labels, missing-value codes, measure & display settings, file encoding β€” instead of the data itself.

# machine-readable, for a documentation pipeline
qsv readstat --metadata pretty-json panel.dta > panel_dictionary.json

Note

In the JSON forms the per-variable array is named columns for SAS and variables for Stata/SPSS, and the keys inside it differ by family too. The csv form flattens whichever shape it finds, deriving its columns from the data, so you do not have to branch on the source package.

A data dictionary from the file's own metadata

New in 24.0.0: --dictionary <file> writes, alongside the CSV, a draft 2020-12 JSON Schema data dictionary in the same shape describegpt --dictionary writes β€” but built from the file's own metadata, with no LLM involved (#4728). Name it <stem>.schema.json after the CSV and viz smart finds it on its own.

qsv readstat --dictionary survey.schema.json survey.sav -o survey.csv
qsv validate survey.csv survey.schema.json      # validate against it
qsv viz smart survey.csv                        # picks up survey.schema.json
  • Variable labels become each column's title; value labels go under x-qsv.value_labels; SPSS declared missing values under x-qsv.missing_values; SPSS measure becomes x-qsv.role (measure / dimension).
  • enum is added only when the labels cover every value written, so validate never rejects the data the dictionary describes.
  • It describes the CSV as written β€” --value-labels, --compress-numeric, --select/--limit/--sample all shape it β€” and records the options used under x-qsv.readstat_flags, which is what lets writestat undo them.

Memory & ordering

Warning

SPSS portable (.por) files are read whole. There is no chunked reader for .por upstream, so --batch does not bound its memory β€” peak usage scales with the file size. This is the only reason the command is marked 🀯; every other supported format streams with constant memory. For a large .por, convert on a machine with headroom (or ask the publisher for a .sav).

New in 24.0.0: SPSS .sav/.zsav files are also read whole while their user-defined missing values are tracked: with --sentinels-as value|label, or by the default sentinel check below when a variable declares missing values. Pass --sentinels-as none to stream them.

--jobs trades memory, not order. Row order is preserved at any --jobs setting. Above one thread the readers take a chunk-interleaving path, and out-of-order chunks are buffered back into source order β€” which costs memory, not correctness. The default of 1 therefore keeps memory flat; raise it to speed up large uncompressed files.

Note

Stata extended missing values (.a through .z) rendered as NaN, not as empty cells, on 23.0.1. Ordinary missing values do come out empty.

New in 24.0.0: fixed upstream in polars-readstat-rs 0.23.2 (#4627). Stata .a-.z now come out as empty cells in every storage type. To keep them, use --sentinels-as value|label, which also works for SAS and SPSS user-defined missing values (#4625).

New in 24.0.0: dropped sentinels are reported (#4708). Without --sentinels-as, readstat still writes them as empty cells, but warns at the end how many it dropped, in which variables, with an example:

5 sentinels (user-defined missing values) in 3 variables (income, rating, name) were written as empty cells, e.g. 99 in income. Use --sentinels-as value to keep them, or --sentinels-as none to skip this check.

The output is unchanged. --sentinels-as none skips the check. It is also skipped at --jobs above 1, because tracking sentinels reads the file on a single thread. For SPSS files that declare missing values, the check costs about +76% time and reads the file whole (see above).

Current limitation β€” conversion only

Note

readstat is a standalone conversion command (#4542 remains open). SAS/Stata/SPSS files are not yet transparently readable as input by other qsv commands the way .zip / .gz / Parquet inputs are β€” qsv stats data.sas7bdat will not work. Convert to CSV first. To carry the variable metadata along, use --dictionary (24.0.0+), which validate, viz smart and writestat read.

readstat rides on the polars feature (βœ¨πŸ€―πŸ»β€β„οΈπŸš€) β€” it is in qsv, qsvpy* and qsvmcp, but not in qsvlite or qsvdp, and like viz it is absent from the big-endian targets (s390x, ppc64 BE). See Binary Variants.

See also: /docs/help/readstat.md, excel β€” the spreadsheet equivalent, stats, describegpt, MCP Server β€” exposed to LLM clients as the qsv_readstat tool, Recipe: Round-Trip SAS / Stata / SPSS.

writestat

New in 24.0.0. The inverse of readstat: write a CSV as an SPSS (.sav, .por), Stata (.dta) or SAS transport (.xpt) file (#4728).

Warning

EXPERIMENTAL. Check the files it writes in the package that will read them before relying on them.

The format comes from the --output extension, or --format sav|por|dta|xpt|xpt5|xpt8 (.xpt is transport v8 by default). --compress writes a bytecode-compressed .sav. Compressed .zsav files and .sas7bdat datasets can't be written. --output is required; the CSV can come from stdin.

The round trip. Hand writestat the dictionary readstat --dictionary wrote, and the file comes back with its metadata: variable labels, value labels, SPSS missing values (discrete, range and string), measures, alignments, display widths & formats, the file label and SAS dataset name β€” each where the output format can hold it. What readstat did to write the CSV is undone: values decoded by --value-labels go back to their codes, and --sentinels-as sentinels become declared missing values again. Round trips are identical for every SPSS .sav fixture and for the SAS fixtures written as transport v5 and v8.

# SPSS β†’ CSV (+ dictionary) β†’ back to SPSS, metadata intact
qsv readstat --dictionary survey.schema.json survey.sav -o survey.csv
qsv writestat --dictionary survey.schema.json survey.csv -o survey2.sav

# the same data as a Stata file
qsv writestat --dictionary survey.schema.json survey.csv -o survey.dta

# no dictionary: column types are inferred from the CSV
qsv writestat data.csv -o data.xpt

A dictionary from describegpt --dictionary works too β€” its titles become variable labels.

For a worked, verified example β€” edit the CSV in between, prove the metadata survived, and see what --lossy drops going to Stata β€” see Recipe: Round-Trip SAS / Stata / SPSS.

Refuses rather than silently dropping. Metadata the output format can't hold β€” value labels & missing values in SAS transport and SPSS portable files, missing values & file labels in Stata, labels too long for SAS transport, column names Stata would rename, Stata tagged missing values β€” is an error. --lossy writes the file anyway and warns about each thing left out. Names a format can't hold at all (too long, or not valid SPSS variable names in .sav/.por) are always an error; fix them first with rename or safenames.

Flag Description
--dictionary <file> JSON Schema data dictionary giving the variable metadata & types
--format <fmt> sav, por, dta, xpt, xpt5, xpt8 β€” when not implied by --output
--lossy Write even if metadata has to be left out, warning about each
--compress Bytecode-compress an SPSS .sav
--table-name <name> Dataset name in a SAS transport file

writestat is gated with readstat (βœ¨πŸ€―πŸ»β€β„οΈ) β€” the same binaries carry it. See /docs/help/writestat.md.

to

CSV β†’ other formats. Subcommands: parquet, xlsx, ods, sqlite, postgres, datapackage. Each can take multiple input CSVs (or a directory, or a .infile-list).

Example: convert NYC 311 to Parquet with zstd compression

qsv to parquet --infer-len 0 output_dir/ NYC_311_SR_2010-2020-sample-1M.csv
ls output_dir/
# NYC_311_SR_2010-2020-sample-1M.parquet

The parquet subcommand needs the polars feature. Default compression is zstd; override with --compression {zstd,gzip,snappy,lz4raw,uncompressed} and --compress-level N.

Column types are inferred from the first 1,000 rows by default (or taken from a current <file>.pschema.json, e.g. from qsv schema --polars). If a later value doesn't fit an inferred type, the whole conversion fails β€” on this file, a ZIP+4 such as 48090-2123 deep in the Incident Zip column, which the first rows make look like integers. --infer-len 0 scans every row before choosing types; a larger --infer-len N is the cheaper middle ground. to parquet also refuses to overwrite an existing .parquet file in the output directory β€” delete it first when re-running.

Note

25.0.0: --all-strings writes every column as text by reading it as text from the start, so type inference can't fail and leading zeros (00501) are kept; --infer-len, any .pschema.json and --try-parse-dates are not used with it (#4807). In 24.0.0 and earlier, --all-strings converted the columns only after the inferred types were applied, so it failed on the same values.

Example: load wcp.csv into a fresh PostgreSQL database

qsv to postgres 'postgresql://user:pass@host/db' wcp.csv
# Creates a table named "wcp"

Example: load multiple related CSVs into a single SQLite database

qsv to sqlite analytics.db nyc311.csv weather.csv stations.csv
# Tables: nyc311, weather, stations

Example: Excel workbook with one sheet per CSV

qsv to xlsx report.xlsx q1.csv q2.csv q3.csv q4.csv
# Sheet names match file stems

Example: Frictionless Data Package β€” CSV + JSON metadata

qsv to datapackage datapackage.json wcp.csv country_continent.csv

Example: include stats in the Data Package

qsv to datapackage --stats datapackage.json wcp.csv

--stats runs qsv stats against each CSV and embeds the result in the Data Package descriptor.

See also: /docs/help/to.md, sqlp β€” read Parquet inline with read_parquet, schema --polars, Integrations: DuckDB.

json

JSON array β†’ CSV. The input must be a flat array (or single object) β€” no nested arrays/objects without a --jaq flatten step.

Example: simple JSON array of fruit records

[
  {"fruit":"apple","price":2.50,"calories":95},
  {"fruit":"banana","price":1.00,"calories":105}
]
qsv json fruits.json > fruits.csv

Example: extract a nested array with jaq

# response.json has structure: { "data": { "items": [ {...}, {...} ] } }
qsv json --jaq '.data.items' response.json > items.csv

See also: /docs/help/json.md, jsonl, tojsonl, jaq syntax.

jsonl

JSONL / NDJSON β†’ CSV. The first JSON line determines the headers; subsequent lines must be consistent.

Example: GitHub events stream

qsv jsonl events.jsonl > events.csv

Example: pipe an API response through jaq, then jsonl

curl -s 'https://api.github.com/events' \
  | jaq -c '.[]' \
  | qsv jsonl > events.csv

See also: /docs/help/jsonl.md, fetch β€” fetch writes JSONL by default.

tojsonl

CSV β†’ JSONL with smart type inference. Runs stats first to determine the right JSON type (string, number, boolean, null) for each column. Reuses an existing stats cache.

Booleans are inferred when cardinality = 2 and values match t/f, 1/0, y/n, or <value>/null patterns.

Boolean inference fixes (Unreleased, after 25.0.0, #4830). The two-value check is case- and whitespace-insensitive, so True/False/false/TRUE is a boolean column. In 25.0.0 that only worked when no other column had a small number of distinct values: adding one (e.g. a y/n column) left True/False as strings. Also, without --trim, a padded value like True was converted to false; it now converts to true.

Example: convert wcp.csv to JSONL with typed values

qsv tojsonl wcp.csv > wcp.jsonl
head -1 wcp.jsonl
# {"Country":"ad","City":"aixas","AccentCity":"AixΓ s","Region":"06","Population":null,"Latitude":42.4833333,"Longitude":1.4666667}

Note Population is null (not "") and the coordinates are numbers (not strings).

Example: pre-populate the stats cache, then convert (skips the type-inference scan)

qsv stats --cardinality --infer-dates --stats-jsonl wcp.csv
qsv tojsonl wcp.csv > wcp.jsonl

See also: /docs/help/tojsonl.md, jsonl, stats, Stats Cache & Caching.

snappy

Streaming Snappy compression / decompression. Four subcommands: compress, decompress, check (first 50 bytes), validate (whole file).

Most qsv commands already handle .sz files transparently β€” the snappy command is the explicit interface when you need:

  • Multithreaded compression (5–6Γ— faster than the auto-compression)
  • Compression of non-CSV files (it's format-agnostic)
  • Integrity check / validation of an existing .sz

Example: compress a 15 GB NYC 311 export (multithreaded)

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

Example: decompress to stdin for inline pipelines

qsv snappy decompress nyc311-full.csv.sz | qsv slice --len 100 | qsv table

Example: validate a .sz file is well-formed

qsv snappy validate suspicious.csv.sz && echo "OK" || echo "Corrupt"

Example: implicit compression via output filename

qsv slice --len 1000 wcp.csv --output sample.csv.sz
# qsv compresses on write (single-threaded). Use `qsv snappy compress` for multithreaded.

See also: /docs/help/snappy.md, Performance Tuning β€” when streaming compression pays off, Indexing, Compression & Diff.

Special-format detection & auto-decompression

qsv inspects each input and, when it recognizes a non-CSV "special format," converts it to delimited text before the command runs. As of v21.1.0 this layer became both more convenient and stricter.

Transparent auto-decompression (more convenient). Compressed inputs now just work β€” no manual unpack step:

  • Local .zip inputs (and lookup tables) β€” the first tabular entry is extracted automatically.
  • 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 by most commands.

Zip selection is now consistent. The command-level "extract all entries" path (cat, sqlp, to, validate, scoresql) and the reader-level "first tabular entry" path (every other command) share one zip module with a single 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.

Conversion failures are now errors (stricter). When a detected special-format input β€” Avro, Parquet, Arrow IPC, JSON, JSONL, or a gzip/zlib/zstd/snappy-compressed CSV/TSV/SSV β€” fails to convert, qsv previously fell back to reading the original (often binary) bytes as delimited text, emitting garbage with a success exit code. That silent fallback is gone: a conversion failure is now reported and qsv exits non-zero (matching the long-standing behavior for .zip).

If you genuinely want the old "read raw bytes as delimited text" behavior, opt in explicitly with the QSV_SKIP_FORMAT_CHECK environment variable (see Environment Variables).

See also: Indexing, Compression & Diff β†’ Transparent input decompression.

geoconvert

Convert between CSV (or SVG) and various spatial formats β€” GeoJSON, SHP, KML, GPX, and more. Pairs nicely with geocode.

Example: GeoJSON β†’ CSV

qsv geoconvert nta_boundaries.geojson geojson csv > nta.csv

Example: CSV with WKT geometry β†’ GeoJSON for QGIS

qsv geoconvert parcels.csv csv geojson --geometry geometry > parcels.geojson

Example: stdin input via prompt

qsv prompt -m 'Choose a GeoJSON file' -F geojson | qsv geoconvert - geojson csv

See also: /docs/help/geoconvert.md, Geospatial, geocode.

prompt

OS-native file dialog (input or output). Useful for ad-hoc analyses where you don't want to copy-paste paths.

Example: pick a file, summarize it, view results

qsv prompt | qsv stats | qsv lens

Example: pick a spreadsheet β†’ CSV β†’ save dialog for the result

qsv prompt -m 'Select a spreadsheet' -F xlsx,xls,ods \
  | qsv excel - \
  | qsv prompt -m 'Save exported CSV to...' --fd-output

prompt is part of the UI feature group (βœ¨πŸ»β€β„οΈπŸ–₯️) β€” not available in qsvlite / qsvdp / qsvmcp.

See also: /docs/help/prompt.md, clipboard, lens.

clipboard (cross-reference)

See Selection & Inspection β†’ clipboard. TL;DR:

# Read from clipboard
qsv clipboard | qsv stats | qsv table

# Write to clipboard
qsv stats wcp.csv | qsv table | qsv clipboard --save

See also

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