CSV, but self-describing

Types, units, stats, and SQL sit in the file — not in a README. The rows stay CSV. pandas, Excel, and awk don't notice.

v0.6 — Draft

#!excsv version=0.6 delim=comma header=1 rows=4 sql-dialect=postgres
#@source: analytics_db.events
#column name=date type=date
#column name=event type=string
#column name=count type=int
#$ddl: CREATE TABLE events (date DATE, event TEXT, count INTEGER)
#%sum: ,,1250
date,event,count
2026-01-15,signup,320
2026-01-15,purchase,180
2026-01-16,signup,410
2026-01-16,purchase,340

What's in the file

Tabular exports are everywhere; the note explaining them isn't. You infer types, rescan for totals, rebuild DDL from memory. ExCSV is that note — written as # lines any CSV reader already skips.

Still CSV

Metadata is comments. The data section is ordinary rows. Nothing breaks.

Types & units

01720 is a ZIP code. 1050.50 is USD. Write it once, stop guessing.

Stats in the file

Sum, avg, count, min/max on #% lines. The total is a fact, not a second pass over 2M rows.

SQL travels with it

DDL and queries, tagged postgres / mysql / clickhouse. Recreate the table where you're loading.

Unix tools just work

grep, awk, cut on the rows. pandas: comment="#".

Add layers as you go

Header, then types, then stats, then SQL. Stop whenever it's useful. Line-oriented key=value — not a nested blob.

Five ways to ship it

Same language. Different packaging — whether you can touch the CSV, how big it is, one table or several — plus a JSON serialization for when the consumer doesn't speak CSV.

LLM-friendly

Made for AI assistants, too

Paste a table into a chat. The model gets schema, types, units, and totals up front — no guessing the delimiter, no inferring types from five rows, no recomputing the sum.

Types & units

An LLM reads 1050.50 as USD, not a count, and doesn't treat a ZIP code as an integer.

Aggregations as a check

#% rows carry sum, avg, count. The model answers — and sanity-checks its own math — without scanning every row.

Grain, spelled out

#@grain: one row per order says what a row is, instead of forcing the model to reverse-engineer it from column names.

Roles & enums

role= (id / dimension / measure / time) and enum= tell it what to group by, what to sum, and what not to invent.

#@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
#%sum: ,,1050.50

Agent skill

Teach Claude Code, Codex or Cursor to read ExCSV meta first: answer from #% and #@grain, load rows in the declared dialect, never sum an id or a balance. One SKILL.md, no dependencies.

Inspired by

ECSV — Astropy

A CSV can carry a versioned header and per-column schema as # lines and still round-trip through every plain reader. ExCSV keeps the shape, swaps nested YAML for key=value.

Annotated CSV — InfluxDB

One annotation row, one value per data column. ExCSV's #% aggregations (sum, avg, min, …) inherit that layout.

MetaCSV — sidecar

Typed metadata beside a plain data.csv. Same idea; ExCSV uses # lines and reference= instead of a second CSV vocabulary. See sidecar.

Full credit and a field mapping on the guide.