csv2 is a command-line CSV parser and editor written in Swift. It handles
RFC 4180 CSV, a two-header .csv2 format, Markdown tables, JSON Lines output,
streaming input/output, indexed reads, parallel searches, and protected
columns.
Traditional Chinese documentation: README.zh-TW.md.
The parser, editor, Markdown reader/writer, index, parallel search, encryption, logging, and cross-platform builds are implemented and covered by the test suite. The public Swift module surface is available and has been verified on macOS, aarch64 Linux, WSL, and Windows through the standalone module/client check.
Eight archives are published per version, from v0.1.9 on: macOS and Windows on x86_64 and arm64, and Linux on x86_64 and aarch64 in two kinds, glibc and fully static musl. Each was built, tested and packed on a machine of its own platform, and the packaging script ends by extracting the archive, running the extracted binary and having it read a CSV. v0.1.8 and earlier published four.
curl -LO https://github.com/raliclo/csv2/releases/download/v0.2.2/csv2-0.2.2-macos-arm64.tar.zst
curl -LO https://github.com/raliclo/csv2/releases/download/v0.2.2/csv2-0.2.2-macos-arm64.tar.zst.sha256
sha256sum -c csv2-0.2.2-macos-arm64.tar.zst.sha256
zstd -d csv2-0.2.2-macos-arm64.tar.zst -c | tar -x
./csv2-0.2.2-macos-arm64/csv2 --versionSubstitute macos-x86_64, linux-x86_64-musl, linux-aarch64-musl,
linux-x86_64-glibc, linux-aarch64-glibc, windows-x86_64 or
windows-arm64 for the others. On Linux, -musl is the one to take unless
you have a reason not to: it needs nothing from the machine, and it is what
Homebrew installs. The -glibc archives are the glibc build. v0.1.9 named
them bare linux-<arch>, and up to v0.1.8 those were the only Linux archives.
The macOS archives need macOS 26 or later. The binaries declare 26.0 as
their minimum, and an older macOS refuses to start them; Homebrew says so at
install. This has been true of every release so far -- it followed the macOS
the release was built on -- and is stated, and fixed in the build, from
2026-11-07. Building from source on an older macOS is not supported: two
cases of the suite fail on macOS 15 with Swift 6.1 (SU, SV in
todo/known-defects.md).
The -musl Linux archives are fully static (from v0.1.9 on), linked against musl.
They need no Swift toolchain and no glibc, so they run on any Linux of their
architecture and keep running when the machine's Swift changes. v0.1.6 to
v0.1.8 linked Swift statically but still needed the machine's glibc, libstdc++
and libgcc_s. The v0.1.5 aarch64 binary named its toolchain by version in its
RPATH and stopped starting when that toolchain was replaced. The price is
size, about 57 MB unpacked instead of about 1 MB.
aarch64 Linux archives exist from v0.1.1 on; v0.1.0 has none. The aarch64 build is tested on every run — T47 compares twelve invocations byte for byte against macOS.
With Homebrew — this repository is its own tap, so there is no second one:
brew tap raliclo/csv2 https://github.com/raliclo/csv2
brew trust raliclo/csv2
brew install raliclo/csv2/csv2The brew trust line is not optional. Homebrew 6 refuses to load a formula
from an untrusted third-party tap, and the refusal arrives at brew install,
not at brew tap.
With Scoop:
scoop install https://raw.githubusercontent.com/raliclo/csv2/develop/scoop/csv2.jsonOn Windows, releases after v0.2.1 need no Swift at all. It
links the Swift runtime and Foundation statically, so csv2.exe is about
53 MB instead of 0.7 MB. Its archive also carries the Microsoft VC++ runtime
DLLs it imports (vcruntime140.dll, vcruntime140_1.dll, msvcp140.dll)
beside it, so it starts on a Windows without the VC++ redistributable too. The
packaging script checks this by running the extracted copy with only
C:\Windows\System32 and C:\Windows on PATH. Keep the DLLs in the same
directory as csv2.exe; Windows looks there first. The reason is in
todo/known-defects.md, SR. In short, upgrading Swift from 6.4.1 to 6.4.2
deleted the old runtime, and the installed csv2 stopped working in every shell
opened before the upgrade.
Releases up to and including v0.2.1 need the Swift runtime of the version
that built them, and neither the archive nor Scoop installs it for you.
(Until 2026-11-11 this paragraph opened the section and spoke of every
release. A first draft of the paragraph above drew the line at "a build made
on or after 2026-11-06"; v0.2.1 was built after that date and is still
dynamic, because the static build had not reached develop when it was
tagged.) Their Windows archive holds csv2.exe and nothing else; it loads
swiftCore.dll, Foundation.dll and about a dozen siblings from PATH. An
archive that holds no DLLs is one of these. v0.2.1 was built with
Swift 6.4.2, so install the Swift 6.4.2 runtime — the directory
%LOCALAPPDATA%\Programs\Swift\Runtimes\6.4.2\usr\bin, which the Swift 6.4.2
installer from swift.org creates and
puts on PATH.
A different runtime version may not work, and the failure says nothing. A
v0.1.3 built with Swift 6.3.3, on a machine with only 6.4.0, printed nothing at
all and exited 0xC0000139 (entry point not found) under cmd.exe and 127
under bash and zsh — which reads like "command not found" for a file that is
sitting right there. If csv2 --version prints nothing, check this before
anything else:
where swiftCore.dllIt should name the Runtimes\<version> directory of the Swift that built the
release. (It said Runtimes\6.4.1 until 2026-11-06, a version that no longer
matched the paragraph above it.) A machine with no Swift installed at all has
not been tested with these dynamic builds; expect it not to start either.
A different patch version has been seen to work — v0.1.4, built with 6.4.0, runs on 6.4.1 — and a different minor version to fail, as above. Nothing else has been measured, so the safe choice is the runtime that built the release.
The runtime follows the release, not the latest Swift: v0.1.9 to v0.2.1 were
built with 6.4.2, v0.1.5 to v0.1.8 with 6.4.1, v0.1.4 with
6.4.0, and v0.1.3 and earlier with Swift 6.3.3. If csv2 --version reports
0.1.3, installing 6.4.x is the wrong fix.
Requirements: Swift 6.3 or later, with warnings treated as errors (6.3.3 is
the oldest that passes the whole suite), and on macOS, macOS 26 or later. The build uses plain
Swift source files, Foundation, and Dispatch; it does not use SwiftPM or
SwiftNIO. The -musl build below also needs Swift's Static Linux SDK of the
same version as the toolchain (swift sdk install, with the checksum swift.org
lists); the build stops and names the one it needs when it is missing.
./compile_csv2.zsh
./test/test_csv2.zsh
./install.zshThe build detects macOS, Linux, and Windows. It writes release/csv2 on
POSIX systems and release/csv2.exe on Windows. install.zsh takes:
| Flag | |
|---|---|
--dry-run |
says what it would do and writes nothing |
--prefix DIR |
installs into DIR/bin, creating it if absent |
--dir DIR |
installs into DIR exactly, creating it if absent |
--no-rc |
writes no startup file — so csv2 will NOT run by name afterwards |
--uninstall |
removes the binary and the block it wrote |
They compose: --uninstall on its own looks only in the default location and
exits 0 having removed nothing, so pair it with the same --prefix/--dir you
installed with.
The default is Homebrew's bin — the directory the paragraph below tells you
to avoid for ssh-reachable scripts. --dry-run prints it. Installing elsewhere
leaves any earlier binary on disk and replaces the startup block, so the old one
stays and stops being found.
It writes ~/.zshenv, creating it if absent, and --uninstall removes its
own marked block but not that file. Exit 0 does not mean csv2 runs by name:
under --no-rc it explicitly does not, which is why the installer probes and
prints what it found.
On macOS, install into /usr/local/bin rather than Homebrew's directory if a
script run over ssh needs to find it: a clean non-login shell has
/usr/local/bin on its PATH and does not have /opt/homebrew/bin, which
path_helper adds from /etc/zprofile for login shells only. install.zsh
says which of the two you got.
./compile_csv2.zsh -muslLinux only, and opt-in. It links csv2 fully static against musl with Swift's
Static Linux SDK, so the binary runs on any Linux of its architecture with no
Swift runtime and no particular glibc. It needs the Static Linux SDK that
matches swiftc exactly; it is found where swift_tar and multissh look for it,
or named with SWIFT_STATIC_SDK (the SDK's swift-linux-musl directory). The
build refuses to finish if the result still names a program interpreter.
The default Linux build stays glibc. That is what the Linux VM builds and
tests (compile_csv2_linux.zsh), and -musl changes nothing there. Both are
published, as linux-<arch> and linux-<arch>-musl.
release.zsh reads the platform out of the binary's own header, so a -musl
build is packaged as linux-<arch>-musl beside the glibc archive rather than
over it.
./publish_matrix.zsh 0.2.2 # says what it would do
./publish_matrix.zsh 0.2.2 --publish # tag, wait for CI, download, ./publish.zsh.github/workflows/release.yml builds, tests and packages eight targets, each
on a runner of its own platform: macOS and Windows on x86_64 and arm64, and
Linux on x86_64 and aarch64 as both glibc and -musl. Homebrew on Linux points
at -musl. publish_matrix.zsh pushes the tag,
waits for that run, refuses unless all eight archives came back, and hands them
to publish.zsh, which does the rest with the checks it already has.
--fetch stops after the download.
Publish from your own machine with publish_matrix.zsh. The release is
created with your gh login, so GitHub shows you as the one who released it.
Only where tags cannot be pushed -- a Claude Code cloud session cannot -- run the
workflow by hand with release_version set to X.Y.Z: it tags develop's head,
builds at the tag, and runs the same publish.zsh in a final job, which also
commits Formula/ and scoop/ to develop. Either way the release notes come from
release-notes/X.Y.Z.md, written before publishing, and the line with each
suite's result is generated from the run. A release made this way shows
"github-actions released this", which is why it is the fallback: v0.1.9 was
made so, and from v0.2.0 releases are made from the user's machine.
The input suffix declares the format:
| Suffix | Headers | Rules |
|---|---|---|
.csv |
1 | RFC 4180; quoted commas and newlines are supported |
.csv2 |
2 | English and Traditional Chinese headers; RFC 4180 quoting as for .csv -- quoted commas and doubled quotes both work -- EXCEPT that a record is one line, so a newline inside a field is written as the escape rather than quoted, as is a carriage return |
.md |
recovered | A Markdown table; use --md-table N for a selected table |
other (.txt, .log, …) or none |
0 | One column per line; bytes are preserved verbatim. --json calls this "format":"lines" |
Suffix matching is case-insensitive (DATA.CSV is a .csv), and when a name
has several suffixes the LAST one decides — so data.csv.bak, which is what
--backup writes, reads as line mode rather than as a CSV. The -o path's
suffix declares a format too, and is checked against the input's header count.
--headers 0 on a .csv2 leaves "format":"csv2" beside "headers":0.
A suffix-less file has no structure to contradict, so nothing in it is
suspect. A #, a JSON object, an XML declaration, a Markdown table's rows
and its |---| separator all come back as their own bytes -- which is what
makes a document containing a table editable line by line. A .csv or .csv2
holding a separator row that is the line's only field IS refused, because
there the suffix claims otherwise and a one-column file that looks like Markdown
is -md output under the wrong name. |---|,2 in a two-column file is data at
exit 0 — the same one-field predicate as the # rule below, and the refusal
says so: "is a Markdown separator row and has one field".
A blank line is a record there too, so -insert N '' and -append '' put
a real empty line in -- prose is mostly blank lines, and a document you cannot
insert one into is not editable. A .csv or .csv2 still refuses '',
because there the suffix has said how many fields a record has and an empty
string is not one of them.
Output record separators are always LF, and CRLF input is normalised to LF
on the way in — a file written on Windows reads correctly and comes back out
with LF endings, which is a change to the bytes rather than only to what is
added. -debug announces it (input contained CRLF line endings; normalised to LF); nothing is printed on the normal path. CR and other bytes inside quoted
CSV fields remain data. UTF-8 BOMs are removed; UTF-16 input is refused with a
conversion instruction.
Header rows are omitted from selection output unless -t is supplied. Writing
headerless rows to a .csv or .csv2 path is refused because the suffix would
then misrepresent the file. csv2 does not silently convert between one-header
and two-header formats.
csv2 -r -i data.csv
csv2 -head 10 -t -i data.csv2
csv2 -tail 5 -i data.csv
csv2 -mid 20,30 -i data.csv
csv2 -contains MIT -i data.csv
csv2 -contains MIT --filter -t -o matches.csv -i data.csv-contains --json emits one object per matching CELL, between the two metadata
lines. On a three-record file:
$ csv2 -contains busybox --json -i test/fixtures/TARGET_PACKAGES.csv
{"meta":{"format":"csv","headers":1,"fields":7,"header":["pkg_name",…]}}
{"record":1,"field":1,"header_en":"pkg_name","value":"busybox","line":2}
…four more hit lines, values abridged here…
{"meta":{"records":21,"matched":3}}
The … are this page's, not the program's: five cells match, in three records.
That record shape is not the one -r --json uses. A cell hit carries
record, field, header_en (and header_zh for a .csv2), value and
line, and has no fields object at all — so a script written against
.fields gets undefined on every line. Under -A/-B/-C the shape changes
again, back to whole records with a fields object plus "match": true|false,
which is the only way to tell a hit from its context programmatically.
The trailing records is how far the record numbering got, and matched counts
matching RECORDS — five cells in three records report matched:3, so it is not a
count of the hit lines above it.
Available reading options:
-r read records (the default)
-contains S report matching cells: record:field TAB column TAB value
--filter emit matching records instead of a locating report
--include-headers include header rows in searches
--normalize compare BOTH sides in NFC; can remove matches, see below
-A N -B N -C N record context around matches
-head N -tail N first or last N records; N must be at least 1
-mid a,b inclusive record range; either end may be omitted
-t include header rows in output
-rownum prepend a record-number column
--physical include the source line in addresses; not with --json
--a1 include spreadsheet A1 notation in addresses
-get r:c print one cell value
-count print how many data records the file has
-count prints one line: the number of data records. It counts them without
checking that each has the file's field width, so it can answer confidently for
a file -r refuses — a ragged record at 15000 of 20000 gives 20000 at exit 0.
It does check quote termination, so a file torn mid-quote is refused. It is O(1) when a usable
.index sidecar is beside the file and O(n) otherwise, and it writes no
sidecar either way. A file with only a header row counts 0, which is not an
error. There is deliberately no total in --json's meta: that number is only
known when an index is, so the field would come and go, and a caller not
finding it could not tell an empty file from a run without an index.
-o must name a regular file. -o /dev/null is refused, and the refusal
says why: -o writes a temp file beside the destination and renames it, which a
device node cannot be. To discard output, redirect -so instead.
-count takes no companions and refuses them by name. A companion here is
anything that would change WHAT is produced — a verb, an output shape, a
destination, a search. Flags that only change how the same count is arrived at
or reported — --no-index, --headers, -debug — are not companions and still
work. It is a whole-file
count and does nothing else, so combining it with a verb, an output shape or a
destination is refused: the refusal lists exactly the flags that were typed.
So -contains ZZZ -count is an error, not the file's total.
-mid a,b with a past the last record is an error, and the message names
the total. b past the last record is not: a window that starts inside the
file and asks for more than is there has an unambiguous answer, which is what
is there. The asymmetry exists because empty output cannot be told apart from
"these rows are genuinely empty" -- the same reason -get past the end has
always been an error.
Record numbers count data records, not physical lines.
A header address is something csv2 PRINTS, never something you write. The
locating report shows 0 for a .csv header and 0a/0b for a .csv2's two
rows — and no verb accepts one. -get 0:1, --search-cell 0a:1 and
--search-row 0 are all refused. To search header rows, use
--include-headers.
-contains searches every column;
use --search-cell R:C, --search-row R, or --search-column C to restrict
the search to one cell, record, or column. These scope options are mutually
exclusive with EACH OTHER and require -contains; each of them pairs with
--filter, -A/-B/-C, -rownum and --json — but those four are not all
compatible with EACH other: -rownum --json is refused, and -rownum on a
locating report is accepted and ignored. On a .csv2 a column name
must come from the ENGLISH header row.
--filter, -A/-B/-C and --normalize also require -contains, and say
so when they are given without it. A search that matches nothing still exits
0, so if csv2 -contains X …; then is true on every file — which is not how
grep behaves.
The locating report is record:field, TAB, column name, TAB, value. In the
value a TAB is written \t, a newline \n and a backslash \\, so one line
always describes one cell and the separator can never appear inside one.
--a1 adds the spreadsheet cell to the address: 2:2 [B3]. Its ROW is the
header rows plus the record number — not the physical line — so a value holding a
quoted newline shifts the two apart. It requires -contains and is refused with
--filter, -md and --json. A1 is printed, never accepted: -get C3 is
refused like a header address.
-rownum prepends a record-number column to RECORDS. On a locating report it
is INERT — accepted and ignored — because the report's address already carries
the record number and -rownum is output-side only: it must never shift what an
address means. Under --json it is refused instead, because there it would not
merely be inert: --json names fields rather than numbering them, so the column
would have nowhere to go.
The report's column-name field follows --en/--zh and defaults to English,
including for a hit whose value came from a .csv2's Chinese header row.
-A/-B/-C do not add to that report — they REPLACE it with whole records,
and put a -- line between groups that are not adjacent. Groups that overlap
merge into one and get no separator.
--normalize normalises BOTH the needle and the stored text to NFC before
comparing, and it can make a match DISAPPEAR: in a file holding Jos followed
by a combining acute, -contains Jos matches the raw bytes and does not match
after NFC, because composing the accent replaces the s with ś. It never
changes what is stored. Column names cannot contain : because
that character is reserved by the r:c address syntax. The :hash, :hmac:,
and :enc: suffixes are reserved csv2 protection markers, not user-defined
column names.
csv2 -r --json -i data.csv2
csv2 -r --json --json-ascii -i data.csv2
csv2 -r -t -md --pretty -i data.csv2
csv2 -r -si --headers 1 -so < data.csv--json emits JSON Lines. The first and last lines are metadata; record lines
contain record data. The first metadata line carries header, the column names in the file's own
order, and for a .csv2 also header_zh, the second header row in the same
positions -- so a consumer can use either header row and knows the column
ORDER, which the keys of a fields object cannot give it. header is present
whenever the file HAS a header row, so a .lines file — which has none — carries
no header key at all, and a script that reads column order from it must expect
that rather than an empty list. To get the order WITHOUT the file's records, take
the first line alone: csv2 -head 1 --json -i f.csv | head -1. There is no
header-only verb. With no header row the fields object is keyed by position
as decimal strings — {"1":"one"} — so a script written against column NAMES
gets numbers there, not an error.
The meta line's fields is the width of the HEADER: 3 for a three-column CSV,
and 0 whenever there is no header row — a .lines file, or any file read
with --headers 0. 0 does not mean the width is unfixed: a .csv read with
--headers 0 still pins it to record 1 and still refuses a ragged file. Use it
to size rows only when it is non-zero.
--json-ascii escapes non-ASCII characters, and that is a framing guarantee,
not a cosmetic one. Plain --json emits U+0085, U+2028 and U+2029 as their
own bytes. That is legal JSON and legal JSON Lines — the record separator is
\n, and jq or while IFS= read -r are unaffected — but a consumer that
splits on Unicode line boundaries, which is what Python's str.splitlines()
does, sees a record break in the middle of a string and gets unparseable
fragments. --json-ascii escapes all three and costs nothing.
-md and --json are mutually exclusive and say so; there is no single run
that emits both.
meta.format is csv, csv2 or lines, and a Markdown table reports
csv. A .md input is translated to CSV before any reader sees it, so a
consumer cannot tell a Markdown table from a CSV by this field; use the path if
you need to know.
meta.records is how far the record numbering got, which equals the file's
total for every verb except -head and -mid a,b, where the reader stops
early. It is not a record count, and there is deliberately no total — see
-count.
A zero-record read is two metadata lines and nothing between them. A
header-only .csv, or an empty file read as .lines, produces exactly that. A
consumer that slices "lines 2 to n-1" — which the framing above invites —
selects the closing metadata line on such a file and fails on it. Select
structurally instead: a record line is the one WITHOUT a meta key.
-t is accepted and has no effect under --json: the header is already in
the meta line, so there is nothing for it to add. It is inert rather than
refused, unlike -rownum, which would have to invent a key.
Under --filter, --json emits the record shape — record, line,
fields — the same as -r, with no field naming which cell matched. The
locating report is where that lives.
--include-headers adds a sixth key and two zeroes. A hit in a header row
emits record: 0, an extra header_row of "0" (or "0a"/"0b" for a
.csv2), and does NOT increment matched, which counts data records. So a
script gated on matched > 0 reports "no match" while a hit line is sitting in
the stream, and .record fed back to -get is refused, because no verb accepts
a header address.
A consumer that stops early gets exit 141, not 1 — once the output is large
enough to block. csv2 -r --json -i big.csv | head -1 leaves csv2 killed by
SIGPIPE with stderr empty, distinguishable from a failure, which is 1 with an
error object. On a file small enough that everything fits in the pipe buffer,
csv2 finishes before head closes and the status is 0. So handle 0 and 141 as
"the consumer stopped early" and 1 as failure; do not treat 141 as guaranteed.
Under --json a truncated run IS structurally detectable, unlike the CSV
output described under Errors. A fault found part-way through leaves the record
lines already written on stdout and no closing metadata line. Requiring that
line is the cheapest completeness check a consumer can make, and it holds
whether or not any record lines got out: on a small file the fault is found
before the buffer flushes, so stdout is empty — which also has no closing
metadata line, and is caught by the same check.
-md emits Markdown and requires -t. --md-style selects the layout, and the
three names are about PADDING only — none of them changes a value:
--md-style |
What it does |
|---|---|
preserve (default) |
Keeps each untouched row's original spacing. On CSV input there is no original spacing to keep, so this behaves exactly as compact — if you are rendering a CSV into a document for a person to read, ask for pretty. The rows you edited are re-emitted compactly, so it means "preserve the rows you did not touch", not "preserve the source layout" |
compact |
|zstd|1.5.6|BSD| — no padding anywhere |
pretty |
Re-aligns every column to a common width across the whole table |
preserve therefore gives the smallest diff when editing a document in place,
which is what it is the default for. On a CSV input, where no original spacing
exists to keep, it behaves as compact.
--pretty is --md-style pretty — one setting with two spellings, giving
byte-identical output and sharing one bound. Giving both with different values
is refused rather than resolved. Both need -md and are refused without it.
Alignment is by DISPLAY width, per column: each column is as wide as the widest
of its header and its cells, with one space either side. Width is measured on
the ESCAPED text, in grapheme clusters — a CJK character counts 2, a combining
mark 0, and a ZWJ emoji sequence counts as one cluster, which is right on a
terminal that renders the sequence and two columns narrow on one that does not.
For a .csv2 the header measured is the literal <br>-joined string.
Holding a whole table to align it is what CSV2_PRETTY_MAX_BYTES bounds. Over
it, the run exits 1 with an empty stdout and a message naming both ways out.
That cap counts the DATA CELLS' escaped bytes; CSV2_MD_MAX_BYTES counts the
input file's raw bytes. Both read "16 MiB" in the table below and they are not
measuring the same thing.
When a table is selected out of a document with --md-table N, the line on
each --json record is the line in the DOCUMENT — the same number your editor
shows — and --physical's @L is that same number. A record on document line
27 reports 27, however far down the file the table begins. There is no table
offset to add.
--physical cannot be combined with --json; it adds to the address in the
locating report, which --json does not produce. Use --json's own line.
A printed address can be pasted back, and its decoration is checked.
-get 2:1@L5 and -get '2:1 [A3]' both name the same cell as -get 2:1, and
so does 2:1@L5 [A3] when both flags printed it. The decoration is not
ignored: it is a CLAIM about the file as it was when the address was printed,
and csv2 refuses when it no longer holds --
csv2: -get: @L9 says record 2 starts on physical line 9, but in this file
it starts on line 5; the file has changed since that address was printed
That refusal is the point. An address you saved yesterday still names record 2
today, but record 2 may be a different record; an address that carries what it
expected to find can say so instead of editing the wrong row. @L claims the
PHYSICAL line and [A3] claims the SPREADSHEET row and the column -- they
differ exactly when a record spans lines, and each is checked separately.
A claim about physical lines turns off the index seek for that one call, because seeking to a byte offset skips newlines without counting them. It costs a scan, and only on a decorated address.
Use --en or --zh to choose the header language in human-readable output.
--version/-V prints the build version; --help/-h prints the complete
option list.
-si reads stdin and -so writes stdout. Neither loads the whole input into
memory. When
stdin is used, --headers is required, because stdin has no extension to
declare a format with.
For a read, -so is the default and passing it changes nothing. For an EDIT
through a pipe it becomes one of two destinations, and one of them is required —
-o FILE or -so, with --in-place unavailable because there is no named file
to write back to:
csv2 -update 1:1 'X' -si --headers 1 -so < data.csv-count reads stdin too, with -si --headers N like any other read; on a pipe
it always scans, since there is no index beside it. --in-place refuses stdin
and says so, naming -i FILE: there is no file to write back to.
Reading from a pipe is always single-threaded, so -contains never takes the
parallel path there whatever the thresholds allow, and -debug reports
file_bytes=0 — which makes the read_bytes against file_bytes comparison
described under Indexes meaningless on a pipe.
--headers 0 reads the input line by line — one field per line, bytes
verbatim. That is the format a file with no .csv/.csv2 suffix already has,
and this is how stdin or a prose .md asks for it. What a declaring suffix
refuses is DISAGREEMENT: --headers 1
on a .csv2 and --headers 2 on a .csv are both errors, while a --headers
that agrees with the suffix is accepted and does nothing.
--headers 0 is accepted on every suffix, and what it then means depends on
the suffix, which is the part worth reading twice:
| Input | --headers 0 gives |
|---|---|
no suffix, or .txt, .log, … |
one field per line, bytes verbatim — line mode |
.md |
the same: prose, editable and writable back as prose |
.csv, .csv2 |
headerless CSV — still split on commas, with the field count pinned by record 1 |
| stdin | line mode; --headers is required there because there is no suffix |
So on a .csv it removes the header row and nothing else. A ragged file read
that way is still refused, naming record 1 rather than a header that does not
exist.
A .md read this way is prose, and can be edited and written back as prose — for documents that merely
CONTAIN a table rather than being one.
Errors are written to stderr as an English line followed by a Traditional
Chinese line — always exactly two, or exactly one JSON object under --json.
Every failure exits 1.
stdout is NOT empty when a fault is found part-way through a file, and you
have to check the exit status. csv2 streams, and a tool that streams cannot
also promise nothing reached stdout before it stopped: a 537 KB file with a bad
record at 20001 wrote 19,126 good records and then failed, and that truncated
output reads back as a perfectly valid CSV of 19,125 records with no marker of
any kind. csv2 -r -i data.csv > out.csv without testing $? is how you get a
silently shortened file.
csv2 stops at the FIRST fault. A file with two bad records reports the first and says nothing about the second, so cleaning a colleague's file is one round trip per fault. There is no mode that lists them all: reporting a second fault would mean deciding what the first one meant, and a wrong guess there is how a repair tool corrupts a file. Expect to iterate.
Errors found BEFORE reading starts — an unusable flag combination, a missing file, a suffix conflict — do leave stdout empty, and they are most of them.
Under --json a refusal is one object on stderr with code, message and
message_zh. The set of codes is not closed — give your consumer a default
branch. These are the ones observed:
code |
Raised by |
|---|---|
not-found |
the input, output or key file could not be opened |
unknown-flag |
a flag this version does not recognise |
ambiguous-match |
two columns share a name, so an address cannot be resolved |
invalid-input |
a value or address the file cannot satisfy, and some flag conflicts |
conflicting-options |
other flag conflicts |
The split between invalid-input and conflicting-options follows where the
check happens rather than a rule you can predict — --physical --json reports
invalid-input while -md --json reports conflicting-options, and both are
flag conflicts. Branch on the exit status, or treat those two codes as one
class.
-get refuses --json as well, and that refusal REPLACES the error you were
about to get, so an out-of-range record under -get --json is reported as the
flag conflict rather than as the range.
A failed edit leaves the input byte-identical. --in-place writes a private
temporary file and renames it, so there is no state in which the input is half
written; -append --in-place is the documented exception, appending directly.
csv2: vs-sqlite.csv2 declares 2 header row(s) by its suffix, but --headers says 1. The suffix declares the format; --headers is for input with no suffix to declare it. Drop --headers to read the file as it is. Renaming it instead makes the suffix agree with --headers, which is NOT the same thing: a header row then becomes data record 1, at rc=0, and nothing afterwards can tell it was one
csv2:vs-sqlite.csv2 的副檔名宣告了 2 列標頭,但 --headers 說 1 列。副檔名宣告格式,--headers 是給「沒有副檔名可宣告」的輸入用的。請拿掉 --headers,照這個檔案原本的樣子讀它。改檔名是讓副檔名去遷就 --headers,那不是同一件事:一列標頭會因此變成第 1 筆資料,rc=0,而事後沒有任何東西看得出它曾經是標頭
A # line is data, not a comment. CSV has no comment syntax, and a
column named #id is legal, so skipping such a line would mean guessing which
lines are data. The trigger is the field count, and the # only chooses the
message. A line holding exactly ONE field, anywhere in the file, is refused by
naming the # rather than by counting fields, because for that line the count
is two steps from the cause; a # in the MIDDLE gets that message too, naming
the record and the line. A # line with the file's own field count is DATA:
#comment,x in a two-column file is record content at exit 0, and #x,y,z in a
two-column file is an ordinary field-count error that never mentions the #. A
header starting with # is a legal column name — which is what makes #id
legal, two sentences above.
To read such a file as lines without touching it, pipe it in:
csv2 -si --headers 0 < FILE. Reading it under a name with no .csv/.csv2
suffix does the same, and removing the line makes it a CSV. Note that
--headers 0 on a file that HAS one of those suffixes means a headerless CSV,
not lines: the suffix has already declared comma separation.
csv2 -update 12:3 'new value' -i data.csv --in-place
csv2 -update-where 'pending' 'done' -i data.csv --in-place
csv2 -update 12:3 --value-file value.bin -i data.csv --in-place
csv2 -insert 4 'a,b,c' -i data.csv --in-place
csv2 -append 'a,b,c' -i data.csv --in-place
csv2 -delete 4 -i data.csv --in-place
csv2 -delete -cell 4:3 -i data.csv --in-place
csv2 -delete -col 3 -i data.csv --in-place
csv2 -add-column 3 'note,備註' 'todo' -i data.csv2 --in-placeSupported edit verbs are -insert, -append, -delete, -update, and
-add-column, plus content-anchored -update-where. Several may be given in
one invocation. All RECORD NUMBERS refer to the original input and every edit is
applied in one pass — so -insert 2 'NEW' -delete 4 deletes the record that was
4th before the insert, not the one that is 4th after it.
-delete a,b deletes an inclusive RANGE, the same reading as -mid a,b. To
remove records that are not adjacent, repeat the flag:
csv2 -delete 2,4 -i data.csv --in-place # deletes 2, 3 AND 4
csv2 -delete 2 -delete 4 -i data.csv --in-place # deletes 2 and 4, keeping 3Both forms exit 0 and print nothing, so the wrong one costs a record with no sign that anything unintended happened.
-insert N makes the new record number N; the record previously at N and
everything after it shift down. It cannot address one past the end — use
-append for that.
-delete -cell clears a field without changing the field count.
-delete -col removes a column from every record and every header row.
-add-column N NAME VALUE makes the new column number N — the column
previously at N and everything after it shift right — and writes VALUE into that
cell of every existing record. On a .csv NAME is one title; on a .csv2 it is
both, comma-separated, and omitting the Traditional Chinese half leaves that
header cell empty and emits a warning. NAME is always split on commas, so a
title that contains one cannot be expressed.
N may be one past the last column, and that appends. On a two-column file
-add-column 3 is valid and puts the new column at the end; -add-column 4 is
refused, and the refusal states the rule — "this file has 2 columns, so the
highest position is 3, which appends". This is the opposite of -insert, which
refuses one-past-the-end and sends you to -append, so the inference from the
neighbouring verb is exactly wrong.
Two -inserts at the same N resolve in the order they were given, so
-insert 1 A -insert 1 B puts A first and B second. Nothing is dropped and
nothing is refused; a script assembling a command line from parts gets a defined
answer rather than a surprise.
-delete -col may be repeated to remove several columns in one run.
A column may be addressed by NAME anywhere a number is accepted — -update 1:license, -delete -cell 1:license, -delete -col license. Names and numbers
both refer to the column as it is in the INPUT, so an address stays correct
alongside -delete -col or -add-column in the same run.
--in-place writes through a private temporary file and rename, except for
-append, which uses an append-only fast path. -append validates the input
before writing and writes only the appended bytes, while still reading the
existing file to validate it — the WHOLE file, not just its final record: appending to a file with a fault at record 5000 of 8000 is refused, naming 5000. Concurrent appends are serialized
by the operating system for complete writes, but general concurrent edits are
not supported.
-append --in-place does not use a temporary file and does not rename the
result. It opens the existing file for append and writes directly to it. If the
process is interrupted after the write begins, the file may contain a partial
record; the all-or-nothing preservation guarantee for ordinary in-place edits
does not apply to this append-only path.
--truncate-partial discards a trailing incomplete record — which means one
specific thing: the file ends INSIDE an unclosed quote. A file whose last line
merely has too few fields is a different fault and is refused, not truncated. It
works on a plain read as well as a rewrite, which is what you want when holding
a torn file, and is refused with -append, which cannot remove existing bytes.
When it discards, it says so on stderr AT EXIT 0:
csv2: --truncate-partial discarded 12 bytes: an unterminated record beginning at byte 4
stderr is not always empty on a successful run. Warnings are one line,
English only, and do not change the exit status; -add-column without its
Traditional Chinese title emits one too. That is the whole list.
-update-where OLD VALUE requires exactly one data cell to equal OLD in
full. Zero matches, multiple matches, or overlapping repeated updates are
refused before output is written. It is a whole-cell update, not substring
replacement.
--value-file PATH and --value-stdin supply the -update value as exact
bytes, including trailing newlines and whitespace.
They exist because a command-line argument cannot carry every byte. An argument is a NUL-terminated string, so a value containing a NUL is cut there before csv2 ever sees it — silently, at exit 0 — and a literal that is not valid UTF-8 is refused outright. Neither limit applies to these two flags, and that is the whole reason to reach for them:
printf 'before\0after' > value.bin
csv2 -update 1:3 --value-file value.bin -i data.csv --in-place
printf 'from a pipe' | csv2 -update 1:3 --value-stdin -i data.csv --in-placeUnder either flag, -update takes the ADDRESS only — the value slot is gone.
They require exactly one -update (-update-where does not count), cannot be
repeated, are mutually exclusive with each other, and cannot be combined with a
literal value or with -si. --value-file - is not stdin; it is a file named
-. An empty regular file is accepted and blanks the cell at exit 0; a
directory, a device such as /dev/null, and a missing path are each refused
with the path and the reason named.
In a lines file a value is one line. A .lines record is one line, so a
value holding a newline cannot be stored in one cell, and -update refuses it,
--value-file included. A value file written by echo or an editor usually
ends with a newline; write it with printf '%s' instead. To replace one line
with several, use one call: -update for the first line and -insert for
each of the rest. Every position refers to the original file, so the rest all
go before the line that followed:
# line 2 becomes three lines (the middle one empty)
csv2 -update 2:1 ' String s = "a, b";' -insert 3 '' -insert 3 ' foo(1, "x");' \
-i Example.txt --in-placeCommas, quotes and empty lines are kept as typed. When the line being
replaced is the last one, add the rest with -append, since -insert
cannot point past the end. In a .csv none of this is needed: a value with
newlines is quoted and kept in one cell.
--in-place works with --value-stdin — the "refuses stdin" rule elsewhere on
this page is about -si, the INPUT, not about where a value comes from.
Reading the bytes back. -get r:c writes the stored bytes and exactly one
LF terminator, so a round-trip check is cmp <(csv2 -get 1:3 -i f.csv) <(cat value.bin; printf '\n'). The locating report cannot print bytes that are not
UTF-8 and renders such a cell as <non-UTF-8: 58 ff 59>, and --json refuses
the file entirely with invalid-input rather than substituting U+FFFD. So a
column holding arbitrary bytes is readable with -get and not with --json.
--dry-run currently previews -update and -update-where, printing each
changed cell as old -> new, and writes no DATA file. It is not silent on disk
if you also passed -log: the preview is appended there, including the new
value, followed by a line reading dry run: N change(s), wrote nothing. That
line is true of the data file and false of the file it is written in.
Other edit verbs are refused rather than returning empty output that could be
mistaken for “no changes”. --backup with --in-place saves the original
beside the input as INPUT.bak and refuses to overwrite an existing backup — so
it works once per path, and a second backed-up edit needs the .bak moved
aside first. A FAILED edit removes the backup it had just taken, since the
input is unchanged and a leftover .bak would block every later use.
An edit cannot take --json at all, so you cannot get an edit's OWN error
as a JSON object: adding --json to an edit replaces whatever would have been
reported with conflicting-options, a refusal about the flags rather than about
the edit. What you do get is still one parseable object on stderr with code,
message and message_zh, exit status 1 — csv2 -update 1:license X --json -i f.csv --in-place emits exactly that. So a consumer can parse every refusal this
tool produces; what it cannot do is learn from JSON why the EDIT would have
failed, because the run never reached the edit.
An edit whose destination is Markdown MUST pass BOTH -md and -t, and
neither is optional — -t is required for the same reason it is when reading,
because a Markdown table has no shape without its header row. The examples in
this section are CSV edits and show neither.
Editing a .md file without it is refused, naming the suffix, and the file is
left alone. With --md-table N --in-place, only the selected table is replaced;
surrounding prose is carried across and all output line endings are LF.
--dry-run works here as it does everywhere: it prints each changed cell and
writes nothing.
csv2 -hash email -i data.csv -o masked.csv -t
csv2 -encrypt secret -keyfile key.bin -i data.csv -o encrypted.csv -t
csv2 -decrypt secret -keyfile key.bin -i encrypted.csv -o clear.csv -t
csv2 -log audit.log -update 1:2 value -i data.csv -o result.csv-hash without a key is the plain, unsalted SHA-256 of the value. It is
one-way and preserves equality comparisons, but unsalted means anyone can hash
their guesses and compare: for a column of email addresses or postcodes that
recovers the values. That is the reason to supply a key, not a refinement of
it.
-keyfile PATH reads the key from a file — at least 16 bytes, and
head -c 32 /dev/urandom > key.bin is a fine way to make one. --yes is a
DIFFERENT thing, not a spelling of the same one: it uses an ambient key at
~/.multissh/generated/mldsa44-ed25519.key.raw, created by mssh-keygen. Output
keyed that way is reproducible only on a machine holding that file, so prefer
-keyfile for anything you need to reproduce or back up. If both are given,
-keyfile wins.
-encrypt uses ChaCha20-Poly1305 with a fresh nonce, so encrypting the same
input twice gives different bytes; both decrypt.
Each cell is authenticated per column, not per position. The tag covers the ciphertext and the column's name, not the record it sits in. A cell altered on its own, or moved to another column, fails to decrypt; two ciphertexts swapped between records of the same column decrypt cleanly, each to the other's value, and the records are reordered or exchanged without an error. If the position of a value matters, protect the file as a whole by other means, such as a signature over it.
-t is not needed for -hash or -encrypt and changes nothing there — the
examples above carry it, but it is not required. These verbs always write the header row,
because that is where the protection marker lives. -md editing genuinely does
require -t; these do not.
Protected columns are marked in the header: name:hash, name:hmac:KEYID for a
keyed hash, and name:enc:KEYID:SALT for a ciphertext.
The fourth field is a per-file salt, not a nonce: csv2 does not reuse one nonce across a column. The AEAD nonces are per cell, inside each ciphertext; that is why the same plaintext encrypts to different bytes in two rows of one column.
The two KEYIDs are not the same kind of thing:
| Marker | KEYID |
|---|---|
name:hmac:KEYID |
Stable for a given key. The same key gives the same value on every file and every run |
name:enc:KEYID:SALT |
A fingerprint over the key AND that file's salt, so it differs on every encryption even with one unchanged key |
So an :enc: KEYID identifies the key only WITHIN one file. Do not group files
by it to find which key opens them — that finds nothing, and every file appears
to need a different key. A wrong key is reported by decryption, which says both
fingerprints and names the salt.
What an encrypted column still leaks. Ciphertext is the plaintext's length
plus 28 bytes with no padding, and it is stored base64, so a cell holds
4 * ceil((len + 28) / 3) characters. Length is therefore visible in steps of
three: plaintexts of 0, 1 and 2 bytes all store as 40 characters, so an empty
cell is NOT distinguishable from a one- or two-byte one, and 3 and 4 bytes both
store as 44.
The column's own name, every other column, the record count and the row order
are all in the clear. What does NOT leak is equality: two records
holding the same value encrypt to different bytes, which is the concrete
advantage over -hash.
-encrypt, -decrypt and -hash accept --in-place, and --backup applies to
them — the path where losing the original cannot be undone. Encrypting a column that is already
encrypted is refused, and so is decrypting a column that was never encrypted or
was hashed rather than encrypted; hashing is one-way, so there is nothing to
return.
Edits that would put a raw value into a protected column are refused — and that covers more verbs than "edit" suggests:
| Verb on a protected column | |
|---|---|
-update, -update-where, -delete -cell |
refused |
-append, -insert |
refused — a whole record carries a raw value into that column |
-delete -col |
allowed — removing the column writes no raw value |
Under --json, a protected file's meta header carries the marker as stored
(email:hmac:6e9c89ad) while each record's fields is keyed by the BASE name
(email), which is also the name you address with. The two deliberately differ;
a script joining them by name has to strip the marker at the first :. The meta
line also gains a protected object naming each protected column and its kind.
-hash and -encrypt mark BOTH header rows of a .csv2, so a file with
English and Chinese headers gets license:hmac:KEYID and 授權:hmac:KEYID.
Secrets are accepted from key files, not command-line arguments. -debug writes
diagnostics to stderr; -log appends timestamped operation records — plain text,
one line each, append-only, English only, and the file is created if absent:
2026-09-07T15:37:42.384+08:00 INFO csv2 -log a.log -update 1:license <value> -i d.csv --in-place
2026-09-07T15:37:42.388+08:00 INFO update 1:license: "MIT" -> "Zlib"
2026-09-07T15:37:42.398+08:00 INFO wrote 2 records, 2 fields, atomic rename OK
The first line is the invocation with the VALUE redacted; --value-file and
--value-stdin are recorded as themselves, so the record says where the value
came from, and a --value-stdin run can be told from a literal one.
The log holds cleartext cell data, and it is created 0644. That is the point
of it — the second line above is what makes the record useful — but it means
-update, -update-where, -delete and -delete -cell write values into it,
-delete N writes a whole record, and a -contains search records its needle.
Protecting a column does not protect the log beside it: keep it where you would
keep the data, not where you would keep a build artifact. What the log never
holds is a key — -keyfile is recorded as the path, and the key's fingerprint
appears where a key would identify itself.
There is no -key option: secrets on a command line are visible to other
processes and may remain in shell history. Use -keyfile instead.
The optional sidecar is named by appending .index to the complete data-file
name, for example data.csv.index. It is an optimisation, never a source of
truth or a precondition:
csv2 --build-index -i data.csv
csv2 --verify-index -i data.csv
csv2 -contains needle --no-index -i data.csvMissing, stale, corrupt, or unsupported indexes are silently discarded in
favour of a scan. The run exits 0, the answer is correct, and nothing is printed
on either stream — so an index you built for speed can be lost to one changed
mtime and you will not be told. Reads stay right and get much slower. -debug
is the only window onto this and prints index hit or the reason it did not:
$ csv2 -mid 500,500 -debug -i data.csv 2>&1 >/dev/null
csv2: 2026-09-08T01:17:16.211+08:00 DEBUG index hit: record 500 via grid point 257 at byte 6297
csv2: 2026-09-08T01:17:16.212+08:00 DEBUG metrics: read_bytes=65536 file_bytes=76898 peak_rss_bytes=9404416
Every -debug line is prefixed, timestamped and levelled, and goes to stderr.
read_bytes against file_bytes is the evidence that a window was seeked to
rather than scanned for; a discarded index shows the whole file read.
Some verbs create a sidecar without being asked. Above
CSV2_INDEX_MIN_BYTES, -tail and any -update … --in-place write one beside
the data, silently — -r, -count, -head, -mid, -get and -contains do
not. Use --no-index to prevent it.
Editing maintains the index rather than invalidating it, so a file under
active editing keeps its sidecar useful. The exception is two concurrent
-append --in-place runs, where the second warns that it could not update it.
--verify-index checks the sidecar's own integrity — its checksum, header and
shape — and exits non-zero when it is absent or invalid. It does not compare
the index against the data, and its own failure text says so. An unusable sidecar is left on disk unchanged; --build-index
replaces it.
--build-index ignores CSV2_INDEX_MIN_BYTES — an explicit request beats the
heuristic — and combining it with --no-index is refused as a contradiction.
--no-index disables both reading and writing the sidecar; the writing half
matters only for the two verbs above.
-contains can use parallel search, and only -contains — a plain read is
never parallel, whatever the file's size. Output is byte-identical to the
single-threaded path. The "Whole-file search" rows in the measurements below
measure a search, not a plain read, so do not expect that speedup from -r.
Everything below has to hold for the parallel path to run, and -debug names
the first one that does not:
| Requirement | Note |
|---|---|
the verb is -contains |
and not --filter, -A/-B/-C, or a --search-* scope — each of those forces one thread |
| a named file, not stdin | the file has to be seekable |
.csv or .csv2 |
a suffix-less file and a .md are excluded |
a .csv needs an .index |
one record per line has to be PROVEN, and only a built index proves it — so --no-index also turns parallelism off on a .csv |
at least CSV2_PARALLEL_MIN_BYTES |
inclusive at the boundary |
| more than one core |
$ csv2 -contains x -debug -i data.csv 2>&1 >/dev/null | grep single-threaded
csv2: 2026-09-08T02:00:12.643+08:00 DEBUG single-threaded: .csv with no index proving one record per line; build one with --build-index
CSV2_PARALLEL_MAX_BYTES bounds how much is held in flight at once; it is in
the table below.
Environment variables used for controlled testing and tuning:
| Variable | Default | Purpose |
|---|---|---|
CSV2_INDEX_MIN_BYTES |
16 MiB | minimum file size for AUTOMATIC sidecar creation; --build-index ignores it |
CSV2_PARALLEL_MIN_BYTES |
16 MiB | minimum size for parallel search |
CSV2_PARALLEL_CHUNK_BYTES |
4 MiB | search chunk size |
CSV2_PARALLEL_MAX_BYTES |
1 GiB | how much of the file the parallel search holds in flight at once |
CSV2_PRETTY_MAX_BYTES |
16 MiB | the DATA CELLS' escaped bytes that --pretty may hold |
CSV2_MD_MAX_BYTES |
16 MiB | the Markdown input FILE's raw bytes |
CSV2_MAX_BUFFER_RECORDS |
1,000,000 | limit for -tail and context buffers |
./test/test_csv2.zsh
../test_submodules/run_csv2_test.zsh
./verifications/measure.zshThe local suite reports pass/fail/skip counts and exits non-zero on failure. The parent-project runner builds csv2 in the aarch64 Linux guest, compares host and guest output, runs the guest suite, and records measurements.
The guest skips more cases than a host does, and every one of them is an
environment difference rather than a gap in csv2. These kinds have occurred:
the payload sent into the guest carries no .git, so cases that read the
repository's own history skip; that image has no iconv and no python3, so
cases needing a second implementation to check against skip; and the guest runs
as root, which cannot be denied read access to a file, so the case that needs an
unreadable one skips.
Counts are deliberately absent here: a number written into a document drifts
away from the suite that produces it, and nothing reports the drift. The
categories are listed because a reader comparing one platform's run against
another's would otherwise read a larger skip count as a regression. The
authority is the run itself -- run_csv2_test.zsh names every skipped case,
by name, in the report it writes each time.
These are recorded wall-clock durations for the benchmark corpus, shown in microseconds (µs). They are not a fixed per-record guarantee; process startup, storage, corpus size, and host conditions are included. The macOS and Windows runs used 200,000 records (25.4 MiB); the Linux guest run used 20,000 records (2.48 MiB), so compare only like-for-like rows.
Each figure is one measure.zsh run, which is itself best-of-N per row, and
each column is the file named beside it. Nothing else on this page is measured
across machines the way these are, so the file names are given rather than
implied:
| Column | File | Measured |
|---|---|---|
| macOS arm64, sparse image | measure_output.txt |
2026-09-10, c0ea3c5 |
| macOS arm64, local SSD | measure_output_macos_ssd.txt |
2026-09-10, c0ea3c5 |
| WSL2 x86_64 | measure_output_linux_x86_64.txt |
2026-09-10, 62d1e1c |
| Windows x86_64 | measure_output_windows.txt |
2026-09-10, 62d1e1c |
| Linux aarch64 guest | measure_output_linux.txt |
2026-09-10, 35a951c |
c0ea3c5..62d1e1c touches verifications/measure.zsh and nothing under src/,
so the four 2026-09-10 columns ran the same csv2. The guest column is older and
says so.
| Measurement | macOS arm64 sparse image |
macOS arm64 local SSD |
WSL2 x86_64 |
Windows x86_64 |
Linux aarch64 guest |
|---|---|---|---|---|---|
| Whole-file search, single-threaded | 544,000 µs | 534,000 µs | 1,179,000 µs | 1,822,000 µs | 62,000 µs |
| Whole-file search, parallel | 205,000 µs | 201,000 µs | 456,000 µs | 820,000 µs | 70,000 µs |
| Small durable edit | 56,800 µs | 7,800 µs | 11,200 µs | 74,600 µs | 13,800 µs |
| Full-file rewrite | 1,258,000 µs | 587,000 µs | 1,323,000 µs | 2,131,000 µs | 155,000 µs |
WSL2 and the guest are both Linux, so their output files are named by
architecture, and measure.zsh refuses to write over a file whose recorded
host names a different machine.
The two macOS columns are the same machine, the same binary and the same minute. Only the filesystem holding the corpus differs. The two read rows do not notice it — 2% — and the two write rows differ by 7.3× and 2.1×, so the write rows measure the storage as much as they measure csv2.
The write rows move between runs; the read rows do not. Across seven runs the sparse image gave 35,800–56,800 µs for the small edit and 1,201,000–1,322,000 µs for the rewrite, the SSD 5,800–7,800 µs and 587,000–619,000 µs. Both read rows stayed inside 2%. The small-edit ratio between the two media was 4.8× in one run and 7.3× in another a fortnight later, on the same two filesystems. A single write figure from either medium is worth about as much as its range says it is.
The guest says the same thing on a different machine and a different filesystem. The same source was measured there three times, giving 9,000, 11,600 and 13,800 µs for the small edit and 150,000-155,000 µs for the rewrite, while both read rows came back inside 1,000 µs of each other every time. Two machines that share no hardware agree on which rows are stable, which is a stronger claim than either could make alone.
The guest's parallel row is SLOWER than its single-threaded row, and that is
the real result. On a 2.48 MiB corpus the boundary-finding pass and the worker
handoff cost more than four workers save: the harness reports speedup 0.88x on 4 workers. It is a genuine parallel run — the harness refuses to report a
row that did not take the path the row is named after — and it is the honest
answer to "should I parallelise a small file".
The source measurements are kept in verifications/measure_output*.txt and
are produced by verifications/measure.zsh using best-of-N timings.
RECORDS=20000 ./verifications/measure.zsh measures a smaller corpus, which is
how a like-for-like comparison against the guest column is made; the script
takes no positional argument and ignores one silently. It also sets
CSV2_PARALLEL_MIN_BYTES itself on each row, to force the path that row is
about, so exporting that variable before running it has no effect.
Where the corpus lives changes these numbers more than anything else on this page. The harness builds it inside its own directory, so a checkout on a sparse image measures that sparse image.
The parallel-search measurement uses the same 10,000,000 data records in a
1,307,777,815-byte .csv file and a 1,307,777,833-byte .csv2 file. Every
record matches needle. Measured 2026-09-10 at c0ea3c5 on macOS arm64 (sparse
image), 10 workers, 4 MiB chunks, best of three interleaved rounds; elapsed and
peak RSS are taken from the same run. The source is
verifications/measure_parallel_rss_output.txt,
and every figure below is checked against it by the test suite.
A .csv needs an .index to be searched in parallel at all, so the .csv
rows below have one. The last row is the same file without it, because that
is the most useful tuning fact here.
| Format | Index | CSV2_PARALLEL_MAX_BYTES |
Elapsed | Throughput | Peak RSS |
|---|---|---|---|---|---|
.csv |
yes | default 1 GiB | 9.781 s | 127.5 MiB/s | 52.89 MiB |
.csv2 |
not needed | default 1 GiB | 32.814 s | 38.0 MiB/s | 52.12 MiB |
.csv |
yes | 8 MiB | 39.601 s | 31.5 MiB/s | 51.28 MiB |
.csv2 |
not needed | 8 MiB | 34.552 s | 36.1 MiB/s | 52.59 MiB |
.csv |
none — single-threaded | — | 30.492 s | 40.9 MiB/s | 9.28 MiB |
Without its index a .csv is searched single-threaded, whatever
CSV2_PARALLEL_MIN_BYTES says, and its 9.28 MiB is the scan path's memory.
CSV2_PARALLEL_MAX_BYTES does nothing there: there are no chunks in flight on
the single-threaded path. Size a container from the parallel rows, not from
that one.
What the table says:
- Build the index. 30.492 s to 9.781 s on the same file, same search.
- The cap costs time here and saves no memory. 9.781 s to 39.601 s on the
indexed
.csv, and peak RSS barely moves. With every record matching, the peak is dominated by the output side rather than by chunks in flight, so capping the chunks throttles the search without touching what is actually large. On a corpus where few records match, the same knob is what keeps memory bounded — which is why this is a measurement of one workload and not a rule. - And the cost is not uniform. The same cap slows the unindexed
.csv2by 5% (32.814 s to 34.552 s) and the indexed.csvby 4×. Between two best-of-three runs a fortnight apart, the capped.csvrow moved from 34.259 s to 39.601 s — 16% — so read the ratio, not the second decimal.
This is a measurement, not a performance guarantee; compare only runs with
the same binary, record count, host, storage and search conditions. It is
produced by verifications/measure_parallel_rss.zsh — a separate script from
the one above, and NOT covered by the measure_output*.txt glob — and the raw
output is
verifications/measure_parallel_rss_output.txt.
Throughput here is dominated by OUTPUT, not by input bytes: every record matches
and every match is a line written. That is why this table's MiB/s and the
parallel row of the previous section disagree by a factor of three on the same
host and binary — they are measuring different bottlenecks, and neither is
"the speed of csv2".
What csv2 does not offer, and what to do instead:
| Not offered | What to do instead |
|---|---|
column projection (-cols) |
csv2 -r --json -i f.csv | jq -r 'select(.record) | .fields.license'. The select(.record) is not optional: without it the two meta lines arrive as nulls in your column. Or -get per cell, with csv2 -count -i f.csv for the loop's upper bound. Both go through --json, which REFUSES a file holding non-UTF-8 bytes that -get reads fine |
| an exact whole-cell search | -contains is a SUBSTRING match, so -contains MIT --search-column license also finds MIT-0 and NON-MIT — scoping the column does not make the match exact. -update-where IS exact ("no data cell equals"), so both semantics exist and only one of them searches. For an exact count: csv2 -r --json -i f.csv | jq -r 'select(.record) | select(.fields.license == "MIT") | .record' | wc -l |
| case-insensitive matching | nothing does — -contains mit finds no MIT, and prints nothing at exit 0, which is indistinguishable from "no such data". Fold the case yourself: csv2 -r --json -i f.csv | jq -r 'select(.record) as $r | $r.fields|to_entries[] | select(.value|ascii_downcase|contains("mit")) | "\\($r.record)\\t\\(.key)\\t\\(.value)"' |
| a search that ignores which column it is in | -contains matches a substring in EVERY column, so -contains MIT also finds transMITter — see the last example below. Use --search-column license to scope it, or --search-row/--search-cell |
skipping # comment lines |
nothing does, and deliberately: a # is data and #id is a legal column name, so skipping one would mean guessing which lines are data. The refusal names the # on any ONE-FIELD line, wherever it is — but the trigger is the field count, so #comment,x in a two-column file is data at exit 0. Read the file under a suffix-less name to get every line verbatim |
| a header-only read | csv2 -head 1 --json -i f.csv | head -1 | jq -r '.meta.header[]' — the meta line carries header; no verb returns the names alone. On a .lines input there is no header key at all |
converting between .csv and .csv2 |
refused on purpose. To do it by hand, .csv → .csv2: csv2 -r -t -i p.csv -o work (the -t is required — without it the header row is dropped silently), then csv2 -insert 2 '套件,版本,授權' -i work --in-place, then rename it: mv work out.csv2. The rename is the step that converts; reading the suffix-less path back gives you .lines, which is what it already was. Reverse: csv2 -r -t -i p.csv2 -o work, csv2 -delete 2 -i work --in-place, mv work out.csv. csv2 will not invent a header row it was not given |
| safe concurrent writers, EXCEPT append against append | serialise them yourself; two writers silently lose one edit — the file stays VALID, and the surviving version is whichever run renamed its temp file last, so the loss is a missing edit rather than damage you would notice. Two concurrent -append --in-place runs are the exception: both records land whole, and the one finishing SECOND warns it could not update the index |
Editing a Markdown table is supported; it is in the examples below.
Every refusal is machine-readable under --json, an edit's included: an
edit given --json emits one parseable object with conflicting-options. What
an edit cannot give you is the edit's OWN error as JSON, because --json is
refused before the edit runs.
Self-contained: every command here runs against a file the first block makes, and every output is what this version actually prints.
$ printf 'pkg,version,license\n套件,版本,授權\nzlib,1.3.1,MIT\nzstd,1.5.6,BSD\n' > pkgs.csv2Read the records. The two header rows are not records, so they are not printed:
$ csv2 -r -i pkgs.csv2
zlib,1.3.1,MIT
zstd,1.5.6,BSD
$ csv2 -r -t -i pkgs.csv2
pkg,version,license
套件,版本,授權
zlib,1.3.1,MIT
zstd,1.5.6,BSDOne cell, by name, with nothing around it:
$ csv2 -get 1:license -i pkgs.csv2
MITThe locating report is three TAB-separated fields: address, column name, value. It is not CSV, and that is on purpose — a value containing a comma would break a CSV report:
$ csv2 -contains MIT -i pkgs.csv2
1:3 license MIT--json emits TWO metadata lines, and neither is a record. The first says
what csv2 believes it is reading; the last carries the counts. A parser that
treats every line as a record meets the first one immediately:
$ csv2 -r --json -i pkgs.csv2
{"meta":{"format":"csv2","headers":2,"fields":3,"header":["pkg","version","license"],"header_zh":["套件","版本","授權"]}}
{"record":1,"line":3,"fields":{"pkg":"zlib","version":"1.3.1","license":"MIT"}}
{"record":2,"line":4,"fields":{"pkg":"zstd","version":"1.5.6","license":"BSD"}}
{"meta":{"records":2,"matched":0}}A Markdown table. Both header rows travel in one cell separated by <br>, and
--pretty aligns by DISPLAY width, so the CJK titles count two columns each:
$ csv2 -r -t -md --pretty -i pkgs.csv2
| pkg<br>套件 | version<br>版本 | license<br>授權 |
|-------------|-----------------|-----------------|
| zlib | 1.3.1 | MIT |
| zstd | 1.5.6 | BSD |See an edit before making it. --dry-run prints the change and writes
nothing:
$ csv2 -update 1:version '1.3.2' --dry-run -i pkgs.csv2 --in-place
update 1:version: "1.3.1" -> "1.3.2"
$ csv2 -update 1:version '1.3.2' --backup -i pkgs.csv2 --in-place
$ csv2 -r -i pkgs.csv2
zlib,1.3.2,MIT
zstd,1.5.6,BSD
$ ls pkgs.csv2*
pkgs.csv2 pkgs.csv2.bakAn edit anchored on content refuses when the match is not unique, and names every address it found rather than picking one:
$ printf 'k,v\n甲,乙\na,x\nb,x\n' > dup.csv2
$ csv2 -update-where x Z -i dup.csv2 --in-place
csv2: -update-where "x": more than one data cell matches (1:2, 2:2); refusing an ambiguous update
csv2:-update-where「x」:有多個資料儲存格符合(1:2, 2:2);拒絕這個有歧義的更新Refusals are readable by a program under --json — one line on stderr,
with a stable code. The exit status stays 1:
$ csv2 --json -i nosuch.csv
{"error":{"code":"not-found","message":"cannot open input file: nosuch.csv","message_zh":"無法開啟輸入檔:nosuch.csv"}}And the trap worth seeing once: -contains is a substring search across
every column, so it finds the word inside another value too:
$ printf 'pkg,license\n套件,授權\nzlib,MIT\ntransMITter,BSD\n' > lic.csv2
$ csv2 -contains MIT -i lic.csv2
1:2 license MIT
2:1 pkg transMITterMIT. See LICENSE.