Guide
What you can write in an ExCSV file, and why a data scientist would. Every line is optional. The rows stay CSV.
The idea
A file at a glance
You open a CSV. Which column is the amount? Is 01720 a ZIP code or 1720?
Are these all the rows? What's the total — do you trust the preview, or re-sum it yourself?
Those answers used to live in a README. Here they sit in the file, as # lines
every CSV reader already skips. The data does not change. ExCSV only describes it.
sales.excsv
Types, grain, a total, a note, and the rows — one artifact.
order_id,status,amount,created_at
1,completed,500.00,2026-01-15T09:30:00Z
2,completed,300.50,2026-02-20T14:00:00Z
3,pending,250.00,2026-03-01T11:45:00Z
pandas: pd.read_csv("sales.excsv", skiprows=10) — skip the ten # lines by count (comment="#" would cut values that contain a #).
Excel and awk see ordinary CSV. An ExCSV-aware tool also gets the schema and the sum.
The first line
#!excsv names the dialect so nobody has to sniff it.
Leave out what you don't need — defaults fill in (comma, UTF-8, first row is a header).
| You set this… | …when |
|---|---|
delim | It isn't a comma. Names: comma, tab, pipe, semicolon — or the character itself (delim=::). |
quote | Fields are quoted. double, single, or none (default). |
header=0 | There is no header row. Identify columns by index= instead of name=. |
null=NA | Your dump uses a sentinel. Empty cells are already null. |
rows | You want the count in the header — useful when a viewer only loaded the first thousand. |
sql-dialect | Untagged #$ lines should default to postgres, duckdb, … |
reference | This file is a sidecar — it points at the CSV, it doesn't contain rows. |
Tabs usually live in .extsv. Invented keys go as x-team=analytics.
An optional checksum=sha256:… fingerprints the data section — a heads-up if the rows moved, not a lock.
Columns — stop guessing
One #column line per column you care about. Annotate three columns and skip the rest — that's fine.
No #column lines at all is still a valid file. It's just CSV with a header.
That's the answer to "is 01720 a number" and "is that float money."
decimal is exact; float / double are binary floats.
| Type | Use it for |
|---|---|
string | Text, ZIP codes, IDs that look numeric |
int / long | Counts, integer keys (32- / 64-bit) |
decimal | Money, anything that must not round |
float / double | Measurements where binary float is fine |
boolean | true/false (also 1/0) |
date / time / datetime | YYYY-MM-DD, HH:MM:SS, ISO 8601 |
uuid / binary | UUIDs; base64 bytes |
Role — a number you sum vs a number you never sum
type is storage. role is analysis. An int can be an id or a count;
only the role tells you which.
id
Identifies a row. Count, distinct, join. Never sum.
dimension
A category. Group by, filter, count distinct. enum= lists the closed set.
measure
A number to aggregate. agg= says how: sum (revenue), avg (balance, price — don't add months), min/max, none (a ratio).
time
The axis. Group by period, trend, range.
Also useful when you have them: title (display name), unique,
required, min/max, format=0.00,
separator=| if a cell packs several values.
default= is what a database would fill in on insert — it is not applied when you read the file.
An empty cell is null. The file describes reality; it doesn't patch it.
Computed columns — derive, don't duplicate
A column's value doesn't have to be stored. formula= derives it from other columns —
the same idea as a spreadsheet formula or a database's GENERATED column, written down as metadata.
total never appears in the data section and costs zero bytes — a reader computes it on demand
from price and quantity. That's a virtual column: metadata only,
no header cell, no field in any row.
Virtual
materialized absent or 0 — no data, no storage cost, no header cell. Pure definition.
Materialized
materialized=1 — the value is cached as an ordinary column. formula= stays either way.
Materialize it when a downstream tool can't evaluate formula= itself and just needs the number
sitting in the row. Dematerialize it later to cut the cached values back out and shrink the file — the
definition survives:
excsv column materialize total data.excsv
excsv column dematerialize total data.excsv
A virtual column has no position — only a name — so it never takes index=; that also means
formula= requires header=1. This is where pack pays
off most: a virtual column in a .excsv.pack.zip costs zero .col files.
Formulas use a small, portable expression language — arithmetic, comparisons, case when, a
handful of functions (concat, coalesce, round, …) — not raw SQL, so the
same formula= means the same thing regardless of which database eventually reads the generated
DDL (GENERATED … VIRTUAL / STORED; ClickHouse ALIAS / MATERIALIZED).
Grain — what one row is
#@grain is the most useful line you'll write.
one row per order vs one row per customer per day is the difference
between a correct join and a silent double-count.
Other #@ lines are provenance: source, author, created,
exported, license, tool, comment, tags.
Invent a key if you need one — it's kept as-is.
Aggregations — the total is a fact
A #% line is a pre-computed statistic for the whole dataset,
laid out like a CSV row so it lines up under the columns:
Column 3 sums to 154,280.50. Empty slots mean "not this column." Nulls sit out, same as SQL.
Paste the first 50 rows of a million-row table into a notebook or a chat.
#%sum still tells the truth about the full file.
The visible slice can't lie to you. That's the difference between
"the sum of what I can see" and "the sum of the data."
| On | You can write |
|---|---|
| Anything | count_nonnull, count_null, count_distinct |
| Numbers | sum, avg, min, max |
| Strings | len_min, len_max |
Charts — a view, not a task
#column says what a value is. #chart says how a few of them are
meant to be looked at — advisory, like everything else here. Skip it and the file is still valid CSV.
type and name are the only two required attributes on every line.
type is the mark, borrowed from Vega-Lite: bar, line,
area, point/circle (scatter, or a one-axis distribution with
only one channel set), arc (pie — hole= for a donut), rect
(heatmap), boxplot, tick, text, and sparkline — a
compact inline trend with no axes. name is a unique id for that line, so
excsv chart --name top_categories can address one suggestion among several. Every other
attribute is a channel that takes a column name: x, y, color,
theta, size, detail, tooltip, and a few more for
facets and ranges — always a name already declared with #column, never a position.
Bar orientation isn't a separate switch — x=category y=amount draws vertical bars,
swap them for horizontal. Add color= and stack=1 to stack instead of
group. count() stands in for "number of rows" wherever a channel expects a column,
for a histogram or a per-category row count.
Past the compact vocabulary — facets, layered views, two measures on independent scales — drop into
raw Vega-Lite with the escape hatch, the same shape as #$ddl vs #$ddl-mysql:
A chart hint only earns its keep if something can act on it without a browser or an image — which is
exactly the medium ExCSV already targets: a terminal, a CI log, a PR diff. asciicharts
(MIT, stdlib-only Python) renders bars, lines, scatter, pie, heatmaps, boxplots, histograms, and
sparklines as monospace text from a small JSON spec — a direct target for a reference
excsv chart command, so the same file you already grep can draw its own chart.
Notes and links — context on the cell
A spreadsheet lets you pin a remark to one cell: refund pending, test order, exclude.
In a CSV that remark ends up in a chat thread that drifts away from the data. #note puts it
back in the file — on a cell, a row, a column, or the whole table. Like every other # line,
it describes the data and never changes it.
row= points at a position; key= points at the row whose id is
1042, so the note stays on the right order after you sort or slice the file in pandas.
Several notes can share a cell. They aren't data: they don't touch aggregates or the checksum, and
they survive conversion to JSON as a notes array. Put them in a
sidecar to review a file you're not allowed to modify.
Most links in a spreadsheet follow a pattern — every order id opens its page in the CRM. That's a template on the column, not a formula in every cell:
{$} is the cell itself, {customer_id} another column in the
same row; values are URL-encoded, unless the template is a single placeholder — then the value
is the URL. #link covers the one cell that doesn't fit the pattern. A link from an
untrusted file stays untrusted: only http, https and mailto are
ever clickable, and an export to Excel writes real hyperlinks, never =HYPERLINK() formulas.
Try it in the converter — the preview shows links and notes in place.
SQL — the recipe travels too
Recreating a table from a CSV usually means retyping the schema.
Here the CREATE TABLE (and the SELECT that produced the dump) sit in the file.
ddl is schema — run it if you want the table back.
dql is provenance: how the extract was pulled, not something that runs itself.
Several databases, side by side: #$ddl-mysql:, #$ddl-clickhouse:, #$ddl-duckdb:.
A consumer picks theirs. Tools only print the SQL — they don't touch your database.
excsv sql ddl postgres data.excsv | psql Open it
The data section is ordinary CSV. Everything self-describing is a # line above it.
| Tool | |
|---|---|
| pandas | n = sum(l.startswith("#") for l in open("sales.excsv"))pd.read_csv("sales.excsv", skiprows=n) — not comment="#", which cuts values at a # |
| polars | pl.read_csv("sales.excsv", comment_prefix="#") |
| Excel / Tableau | Open the data section, or keep a sidecar and open the .csv |
awk / grep | Meta lines start with # — they skip them for free |
| ZIP | pd.read_csv("sales.excsv.zip", skiprows=n) — schema also sits in the ZIP comment |
More patterns on Examples. A CLI that understands the metadata: tools.
How it ships
Same language. Five formats — details on Formats.
Plain
Schema and rows in one file. The default.
.excsv + .csvSidecar
Annotate a CSV you can't rewrite.
.excsv.zipZIP
Compressed. Peek schema without unzipping.
.excsv.pack.zipPack
Columns and several tables in one archive.
.excsv.jsonJSON
The same document as JSON. APIs, config, AI output.
The JSON form
Same content — columns, types, aggregates, SQL — in a file of its own,
sales.excsv.json, for when you're already in JSON: an API, a config, a prompt.
There's a JSON Schema to validate against,
or to constrain a model's output to. CSV → JSON → CSV doesn't lose anything.
Money stays a string so it doesn't become a float.
{
"excsv": "0.6",
"meta": { "grain": "one row per order" },
"columns": [
{ "index": 0, "name": "amount", "type": "decimal", "unit": "USD", "role": "measure", "agg": "sum" }
],
"aggregates": { "sum": ["1050.50"] },
"data": [[1, "500.00"], [2, "550.50"]]
} Inspired by
ECSV
Astropy showed a CSV can carry schema as # lines. ExCSV keeps the shape, drops the YAML.
Annotated CSV
InfluxDB's one-value-per-column annotation rows. That's where #% comes from.
Worked files on Examples. Parser rules live in the implementation docs.