std.csv

import std.csv

rows = csv.parse(text)          # rows as column-keyed maps
text = csv.stringify(rows)
table = csv.read("data.csv")
csv.write("out.csv", table)
csv.each("big.csv", fn(row) { ... })   # one row at a time, any size

Rows are maps

The first line is the header, and each row becomes a map keyed by column name:

for row in csv.read("people.csv") {
    print("{row.name} is {row.age}")
}

Better than positional access in the way that matters: inserting a column upstream does not shift every index in your code.

Big files: csv.each

csv.read loads the whole file, so it is capped by ECKO_LIMIT_ALLOC (256 MiB by default) and refuses anything bigger with kind: "limit". csv.each reads one row at a time and hands it to your function, so memory stays flat however big the file is. It takes a path, or a stream from fs.open, a socket, a pipe or stdin:

import std.csv
import std.fs

summary = csv.each("transactions.csv", fn(row) {
    load(row)
}, on_error: fn(bad) fs.append("dead-letter.csv", bad.raw + "\n"))

print("{summary.rows} rows, {summary.errors} sent to the dead-letter file")

A row that is wrong - a different number of fields from the header, or bytes that are not UTF-8 - does not end the job. It becomes a value, { kind: "parse", format: "csv", line, raw, message }, handed to on_error; raw is the row written back as one line of CSV, so a dead-letter file of them is itself valid CSV. Without on_error the first bad row is thrown, at that row, with the same fields. csv.each returns { rows, errors }: how many rows reached your function and how many were bad. An error your function throws stops the job and propagates as it is.

Columns are sorted on write

csv.stringify sorts columns, because Ecko maps are unordered - so output is deterministic and diffable. If you need a specific column order, build the rows as a list of lists and write it yourself.

Everything is a string

CSV has no types. Every value parses as a string, including numbers:

row.age             # "42", not 42
int(row.age)        # 42

Convert explicitly, and be ready for a conversion to fail on real data - a blank cell is "", not null, and int("") raises.

Errors

Malformed input raises { kind: "parse", format: "csv" }, with line and raw from csv.each. A file that cannot be read raises { kind: "fs", path }.

What it handles, and what it does not

Quoted fields, embedded commas and embedded newlines inside quotes all work.

CSV in the wild is not one format. CRLF line endings and a UTF-8 byte-order mark are handled, but semicolon separators, inconsistent column counts, latin-1 encoding - any of these can arrive. csv.each turns the last two into per-row errors rather than a failed job. When they do, read the bytes and pre-process, or reach for re/string directly.

For anything with real structure, prefer JSON, TOML or a database. CSV is a transport for tables of text, and treating it as more than that is where the pain comes from.