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.

#!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
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.

#!excsv version=0.6 header=1 rows=4
#@source: sales_db.orders
#@grain: one row per order
#column name=order_id type=long unique=1 role=id
#column name=status type=string role=dimension enum=pending|completed|cancelled
#column name=amount type=decimal unit=USD role=measure agg=sum
#column name=balance type=decimal unit=USD role=measure agg=avg
#column name=created_at type=datetime role=time
#%count_nonnull: 4,4,4,4,4
#%sum: ,,1050.50,,
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.

#!excsv version=0.6 rows=1
## this is a human note
#@source: demo
## comment between meta kinds
#column name=id type=int
#column name=name type=string
## another human note
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.

# report.excsv
#!excsv version=0.6 delim=comma header=1 rows=2 reference=report.csv
#@source: fixtures_db.orders
#column name=id type=int
#column name=customer type=string
#column name=amount type=decimal
#%sum: ,,750.50

# report.csv — byte-for-byte original, never touched
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.

#!excsv version=0.6 checksum=sha256:7cde7fb64fd82bd152710cf238e017b9ab46c0592483edc067ba4f6c75fac108 rows=1
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.