CLI Cookbook
An ExCSV file is still plain, delimiter-separated text — every metadata line starts with
#, so grep, awk, and cut already know how
to skim it. You don't need a parser to answer "how many rows," "what's column 3 called," or
"what's the total" — one shell command does it. Reach for a real library
(Go or Python) once you need error handling,
validation, or to write files back out; these recipes are for the terminal, a notebook cell,
or a shell script glued into a bigger pipeline.
Every command below is verified against the format's own fixture corpus.
Save the sample below as orders.excsv and follow along.
orders.excsv
Inline ExCSV — the sample every recipe in the first sections runs against.
id,customer,amount
1,Acme,100.00
2,Globex,20.50 Read the header
The first line carries everything about the file as a whole — version, delimiter, row count, checksum. It's one long line; here's how to make it skimmable.
Print the header, one attribute per line
$ head -1 orders.excsv | tr ' ' '\n' #!excsv
version=0.6
rows=2
schema=excsv
sql-dialect=mysql
Turns a long key=value key=value … line into a scannable list. (A quoted value with a space inside — rare — would get split too; check for " before relying on this in a script.)
Row count
$ grep -v '^#' orders.excsv | tail -n +2 | wc -l 2 Read the data
The # lines are comments. Drop them and it's a CSV file, full stop.
Strip the metadata — get plain CSV
$ grep -v '^#' orders.excsv id,customer,amount
1,Acme,100.00
2,Globex,20.50 Every ExCSV reader already does this on your behalf. If the file has a UTF-8 BOM, see the BOM gotcha below first.
Pretty-print as an aligned table
$ grep -v '^#' orders.excsv | column -t -s, id customer amount
1 Acme 100.00
2 Globex 20.50 Pull one column by name
$ grep -v '^#' orders.excsv | awk -F, -v col=amount 'NR==1{for(i=1;i<=NF;i++) if($i==col) c=i} {print $c}' amount
100.00
20.50 Looks up the position from the header row instead of hard-coding a column index, so it survives reordered columns.
Filter rows by a numeric threshold, by column name
$ grep -v '^#' orders.excsv | awk -F, -v col=amount -v min=50 'NR==1{for(i=1;i<=NF;i++) if($i==col) c=i; print; next} $c+0>min' id,customer,amount
1,Acme,100.00 Trust the stats — or check them yourself
Sums, averages, and null counts ride along in the header, lined up with the columns in the same order. Find a column's position once, then reuse it.
Find a column's position
$ grep '^#column' orders.excsv | nl -ba -s': ' 1: #column name=id type=int unique=1
2: #column name=customer type=string required=1 len_max=100
3: #column name=amount type=decimal unit=USD min=0 amount is the 3rd #column line.
Read a stat at that position
$ grep '^#%sum:' orders.excsv | cut -d' ' -f2 | cut -d, -f3 120.50
The aggregation lines list one value per column, in the same order as the #column lines above — position 3 is amount in both. Swap #%sum: for #%avg:, #%min:, #%max:, #%count_null:, … — same trick, same position.
Null count for a different column, same trick
$ grep '^#%count_null:' orders.excsv | cut -d' ' -f2 | cut -d, -f2 0 customer is position 2, so this cuts field 2 instead of field 3.
Recompute a total yourself, as a sanity check
$ grep -v '^#' orders.excsv | awk -F, -v col=amount 'NR==1{for(i=1;i<=NF;i++) if($i==col) c=i; next} {s+=$c} END{printf "%.2f\n", s}' 120.50
If this disagrees with the stored #%sum:, the file's stats are stale — the data changed after the aggregates were written.
Schema, provenance, and meaning
List column names
$ grep '^#column' orders.excsv | sed -E 's/.*name=([^ ]+).*/\1/' id
customer
amount List name:type pairs
$ grep '^#column' orders.excsv | sed -E 's/.*name=([^ ]+) type=([^ ]+).*/\1:\2/' id:int
customer:string
amount:decimal Who exported this, and when?
$ grep '^#@' orders.excsv #@author: author@example.com
#@source: sales_db.orders
#@created: 2026-01-01T00:00:00Z
#@exported: 2026-03-24T12:00:00Z
#@license: CC-BY-4.0
#@tool: excsv-cli/0.2.0
#@tags: sales,Q1 sales.excsv
A second sample — adds #@grain and per-column role= / agg= hints for the next two recipes.
order_id,status,amount,balance,created_at
1,completed,500.00,1200.00,2026-01-15T09:30:00Z
2,completed,300.50,900.00,2026-02-20T14:00:00Z
3,pending,200.00,700.00,2026-03-01T11:45:00Z
4,cancelled,50.00,650.00,2026-03-10T16:20:00Z Semantics: grain, roles, aggregation
What does one row mean?
$ grep '^#@grain:' sales.excsv | sed 's/^#@grain: //' one row per order Which columns are safe to sum?
$ grep '^#column' sales.excsv | grep 'agg=sum' | sed -E 's/.*name=([^ ]+).*/\1/' amount balance is a measure too, but agg=avg — summing it across rows would double-count. order_id has role=id: never aggregate it at all. This is the difference between a plain CSV and one that tells an LLM (or a teammate) what not to do with it.
notes.excsv
Human comment lines (##) mixed in with machine metadata.
id,name
1,Alice Human notes, and a real-world gotcha
Read the notes a person left behind
$ grep '^##' notes.excsv | sed 's/^## //' this is a human note
comment between meta kinds
another human note ## lines are for people, not tooling — they can appear anywhere between the machine metadata lines.
The BOM gotcha
$ printf '\xef\xbb\xbf#!excsv version=0.6 rows=1\n#column name=id type=int\nid\n1\n' > bom.excsv
$ grep -v '^#' bom.excsv #!excsv version=0.6 rows=1
id
1
Plenty of real-world CSV exports (Excel especially) carry a UTF-8 byte-order mark, and it sits before the # on line 1 — so grep -v '^#' doesn't match line 1 at all, and the un-stripped header leaks into the "clean" output above (it's just invisible: the BOM is a zero-width character). Fix it with sed '1s/^\xef\xbb\xbf//' before piping into grep -v '^#'.
SQL companions
The DDL that created the table travels with the data. No separate migration file to lose track of.
Recreate the table
$ grep '^#\$ddl:' orders.excsv | sed 's/^#\$ddl: //' | psql mydb
Pulls the unqualified #$ddl: line — the portable fallback — and pipes it straight into psql (swap for mysql, clickhouse-client, …). See SQL companions.
DDL for a specific dialect, with fallback
$ grep -E '^#\$ddl:|^#\$ddl-postgres:' orders.excsv | sed -E 's/^#\$ddl(-postgres)?: //' CREATE TABLE orders (id INT PRIMARY KEY AUTO_INCREMENT, customer VARCHAR(100), amount DECIMAL(8,2)) ENGINE=InnoDB
CREATE TABLE orders (id BIGSERIAL PRIMARY KEY, amount NUMERIC(8,2))
A consumer targeting postgres runs the unqualified line, then the -postgres override — same rule the spec defines for any dialect.
Which dialects does this file cover?
$ grep -o '^#\$ddl-[a-z0-9-]*' orders.excsv | sed 's/^#\$ddl-//' mysql
postgres
clickhouse report.excsv (sidecar) + report.csv
The sidecar profile: metadata in one file, the untouched original in another.
id,customer,amount
1,Acme,500.00
2,Globex,250.50 Sidecar — annotate a file you can't touch
Find the file this sidecar describes
$ grep -o 'reference=[^ ]*' report.excsv reference=report.csv Tell inline from sidecar
$ grep -q 'reference=' report.excsv && echo sidecar || echo inline sidecar
An inline file (like orders.excsv at the top of this page) has no reference= key — it carries its own rows.
signed.excsv
A minimal file carrying an integrity checksum.
id
1 Checksum
Extract the declared checksum
$ grep -o 'checksum=[^ ]*' signed.excsv checksum=sha256:7cde7fb64fd82bd152710cf238e017b9ab46c0592483edc067ba4f6c75fac108 Presence alone tells you the file claims integrity protection; verifying it means recomputing the hash over the canonical form — see the header reference or just use a library.
ZIP — preview a compressed file without unzipping it
A .excsv.zip mirrors its header, columns, and stats into the archive's comment field.
Browsing a folder of zipped exports, you see what each one holds without extracting a single byte.
Zip the orders.excsv from the top of this page as orders.excsv.zip and try:
Peek the schema and stats
$ zipinfo -z orders.excsv.zip Archive: orders.excsv.zip
#!excsv version=0.6 rows=2 schema=excsv sql-dialect=mysql
#@author: author@example.com
#@source: sales_db.orders
#@created: 2026-01-01T00:00:00Z
#@exported: 2026-03-24T12:00:00Z
#@license: CC-BY-4.0
#@tool: excsv-cli/0.2.0
#@tags: sales,Q1
#$ddl: CREATE TABLE orders (id INT PRIMARY KEY AUTO_INCREMENT, customer VARCHAR(100), amount DECIMAL(8,2)) ENGINE=InnoDB
#$ddl-mysql: CREATE TABLE orders (id INT PRIMARY KEY AUTO_INCREMENT, amount DECIMAL(8,2)) ENGINE=InnoDB
#$ddl-postgres: CREATE TABLE orders (id BIGSERIAL PRIMARY KEY, amount NUMERIC(8,2))
#$ddl-clickhouse: CREATE TABLE orders (id UInt64, amount Decimal(8,2)) ENGINE = MergeTree() ORDER BY id
#$dql: SELECT * FROM orders WHERE amount > 0
#column name=id type=int unique=1
#column name=customer type=string required=1 len_max=100
#column name=amount type=decimal unit=USD min=0
#%count_nonnull: 2,2,2
#%count_null: 0,0,0
#%count_distinct: 2,2,2
#%sum: ,,120.50
#%avg: ,,60.25
#%min: ,,20.50
#%max: ,,100.00
Zip file size: 1627 bytes, number of entries: 1
... unzip -z works the same way. Only the header, columns, SQL, and aggregates are mirrored — never the data rows.
Pull clean CSV straight out of the zip
$ unzip -p orders.excsv.zip orders.excsv | grep -v '^#' id,customer,amount
1,Acme,100.00
2,Globex,20.50 unzip -p streams the entry to stdout — no temp file, no cleanup.
sales.excsv.pack.zip
A pack archive holding the same orders table, one column per entry.
sales.excsv.pack.zip
├── _manifest.excsv
└── orders/
├── _header.excsv
├── 00-id.col
├── 01-customer.col
└── 02-amount.col Pack — read one column, not the whole file
A .excsv.pack.zip stores each column as its own .col entry inside a per-table folder.
That means you can read a single column out of a wide table without decompressing the rest of it.
List tables and columns without extracting anything
$ zipinfo -1 sales.excsv.pack.zip _manifest.excsv
orders/_header.excsv
orders/00-id.col
orders/01-customer.col
orders/02-amount.col Read one column, untouched by the rest
$ unzip -p sales.excsv.pack.zip orders/02-amount.col 100.00
20.50
Only that one .col entry gets inflated — the other columns, and other tables in the archive, are never touched. See Pack.
Where this stops working
These commands assume well-formed input and don't validate, handle quoted delimiters inside
fields, follow multi-line #$ddl continuations, or resolve computed
(formula=) columns. That's the line between a cookbook and a parser — cross it
with excsv-golang or excsv-python. Every recipe
above was run against the project's fixture corpus; if you find one that breaks on a real file, open an issue.