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.

#!excsv version=0.6 delim=comma header=1 rows=3
#@source: sales_db.orders
#@grain: one row per order
#column name=order_id type=int 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=created_at type=datetime role=time
#note key=2 col=amount text="Partial refund pending"
#%sum: ,,1050.50,
#$ddl-postgres: CREATE TABLE orders (order_id INTEGER, status TEXT, amount NUMERIC(10,2), created_at TIMESTAMPTZ)
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.

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.

#column name=amount type=decimal unit=USD role=measure agg=sum
#column name=zip type=string

That's the answer to "is 01720 a number" and "is that float money." decimal is exact; float / double are binary floats.

TypeUse it for
stringText, ZIP codes, IDs that look numeric
int / longCounts, integer keys (32- / 64-bit)
decimalMoney, anything that must not round
float / doubleMeasurements where binary float is fine
booleantrue/false (also 1/0)
date / time / datetimeYYYY-MM-DD, HH:MM:SS, ISO 8601
uuid / binaryUUIDs; 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.

#column name=order_id   type=int      role=id
#column name=status     type=string   role=dimension enum=pending|completed|cancelled
#column name=amount     type=decimal  role=measure agg=sum
#column name=balance    type=decimal  role=measure agg=avg
#column name=created_at type=datetime role=time

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.

#column name=price    type=decimal unit=USD
#column name=quantity type=int
#column name=total    type=decimal unit=USD formula="price * quantity"

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.

#@grain: one row per order
#@source: sales_db.orders
#@exported: 2026-03-24T14:30:00Z
#@tags: sales,Q1,2026

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:

#%count_nonnull: ,98,100
#%sum: ,,154280.50
#%avg: ,,1542.81

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."

OnYou can write
Anythingcount_nonnull, count_null, count_distinct
Numberssum, avg, min, max
Stringslen_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.

#chart type=bar name=top_categories x=category y=amount sort=desc limit=10 title="Top categories by spend"
#chart type=sparkline name=amount_trend y=amount title="Amount trend"

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:

#chart-vega: {"mark":"arc","encoding":{"theta":{"field":"amount","aggregate":"sum","type":"quantitative"},"color":{"field":"category","type":"nominal"}}}

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.

#column name=order_id type=int role=id
#note key=1042 col=amount author=alex@example.com text="Refund pending, see ticket 1234"
#note col=email text="Addresses from the old CRM were never validated"
#note text="Q1 numbers are preliminary until the audit closes"

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:

#column name=order_id type=int role=id link="https://crm.example.com/orders/{$}"
#column name=customer type=string link="https://crm.example.com/c/{customer_id}"
#column name=homepage type=string link="{$}"
#link key=1043 col=invoice href="https://legacy-billing.example.com/view?id=A-77"

{$} 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.

#!excsv version=0.6 sql-dialect=postgres
#$ddl: CREATE TABLE orders (id BIGINT PRIMARY KEY, amount NUMERIC(8,2))
#$dql: SELECT id, amount FROM sales.orders WHERE exported_at > '2026-01-01'

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
pandasn = sum(l.startswith("#") for l in open("sales.excsv"))
pd.read_csv("sales.excsv", skiprows=n) — not comment="#", which cuts values at a #
polarspl.read_csv("sales.excsv", comment_prefix="#")
Excel / TableauOpen the data section, or keep a sidecar and open the .csv
awk / grepMeta lines start with # — they skip them for free
ZIPpd.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.

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"]]
}

More on Formats · Converter.

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.

MetaCSV

A sibling file beside plain CSV. Same job as sidecar, different vocabulary.

Worked files on Examples. Parser rules live in the implementation docs.