CSV
CSV lives in runtime:serialization and runs in Rust — parse, validate and build, with no npm package to add.
Parsing CSV
CSV.parse returns an array of field arrays by default, every field a string. Pass header: true to key objects by the first row (which is then consumed).
import { CSV } from "runtime:serialization"; const text = `name,note Alice,"hi, there" Bob,"say ""hi"""`; CSV.parse(text); // [["name","note"],["Alice","hi, there"],["Bob",'say "hi"']] CSV.parse(text, { header: true }); // [{ name: "Alice", note: "hi, there" }, { name: "Bob", note: 'say "hi"' }]
A quoted field keeps its delimiter and newlines, and "" reads back as one ". header can also be an array of names, in which case every row is data:
CSV.parse("1,2\n3,4\n", { header: ["x", "y"] }); // [{ x: "1", y: "2" }, { x: "3", y: "4" }]
Nothing is type-cast unless you ask. cast: true turns plain decimal fields into numbers and true/false into booleans — and leaves anything it cannot read exactly (inf, nan, hex, a decimal too large for f64, an empty field) as the string it was.
CSV.parse("n\n1\n", { header: true, cast: true }); // [{ n: 1 }]
Other options: delimiter, quote, escape, trim, skipEmptyLines, comment, relaxColumnCount, maxFieldBytes, maxColumns, bom and newline. See the API reference for the full table.
Validating CSV
CSV.validate takes the same options as parse, because validity depends on them: a ragged row is fine by default but an error once a header is declared.
import { CSV } from "runtime:serialization"; CSV.validate("a,b\n1,2\n"); // true CSV.validate("a,b\n1\n"); // true (no header) CSV.validate("a,b\n1\n", { header: true }); // false (ragged) const result = CSV.validate("a,b\n1\n", { header: true, detailed: true }); console.log(result.valid); // false console.log(result.error); // "Validation failed: expected 2 field(s) but found 1"
Building CSV
CSV.build writes an array of rows. An array of arrays is written verbatim; an array of objects infers its header from the first object's keys.
import { CSV } from "runtime:serialization"; CSV.build([["a", "b,c", 'd"e']]); // 'a,"b,c","d""e"\n' CSV.build([{ name: "Alice", age: 30 }]); // "name,age\nAlice,30\n"
A field is quoted only when it contains the delimiter, the quote or a line break, or (with comment set) starts with the comment byte, so a round trip through parse keeps the row. An absent (null or undefined) field is written empty. NaN and ±Infinity have no CSV representation and throw a TypeError.
Streaming
For input too large to hold at once, CSV.DecoderStream and CSV.EncoderStream are Web TransformStreams. They take the same options as parse and build, so a CSV file reads at the cost of a row instead of the whole document.
import { CSV } from "runtime:serialization"; import { file } from "runtime:fs"; // Decode: text or byte chunks in, rows out — the same shapes `parse` produces. const rows = file("users.csv") .stream() .pipeThrough(new CSV.DecoderStream({ header: true })); for await (const user of rows) { console.log(user.email); }
A quoted field may span chunk boundaries — its embedded newline, a doubled quote, even the two halves of a multi-byte character — because the scan resumes rather than splitting on \n the way JSONL can. An unterminated quote is reported when the stream ends, not mid-chunk.
Writing streams one row per chunk; the first object fixes the header, exactly as CSV.build infers it.
const encoder = new CSV.EncoderStream(); encoder.pipeTo(file("out.csv").writable()); await encoder.write({ name: "Alice", age: 30 }); await encoder.write({ name: "Bob", age: 41 }); await encoder.close();
How it compares
CSV is the one structured format with no common standard: ECMA-429's Minimum Common Web API covers JSON and structured clone and stops there. Node.js and Bun ship no reader at all, and Deno's lives in @std/csv, a JSR package rather than a core module. Everywhere else it is an npm dependency.
ES-Runtime puts CSV in runtime:serialization beside the other formats, so it arrives with the module, needs no package and takes no capability.
| Default output | Header → objects | Type inference | Streaming | Errors | |
|---|---|---|---|---|---|
| ES-Runtime | string[][] | opt-in (header: true) | off (cast: false) | decoder + encoder | throw |
Deno @std/csv | arrays | opt-in (skipFirstRow) | off | stream pair | throw |
| Node.js / Bun | — (no built-in) | — | — | — | — |
| Papa Parse 5 | string[][] | opt-in (header: true) | off (dynamicTyping: false) | step / chunk | { data, errors, meta } |
| csv-parse | string[][] | opt-in (columns) | off (cast: false) | Node streams | throw |
| fast-csv | arrays | opt-in | off | format + parse streams | throw |
| d3-dsv | objects | always | off | no | throw |
The two choices parse makes are the ones the field already agrees on: rows as field sequences, and no type inference. Objects only when a header is asked for is the shape that loses nothing when the file has none, and a guessed 1 — a ZIP code, a phone number — is harder to get back than to avoid.
Where it goes further than most: streaming is symmetric, decode and encode sharing the writer build uses, so a streamed document and a built one cannot drift, and both directions are boundable — maxFieldBytes and maxColumns cap what a single line can cost.
What the others do that this does not — Papa Parse auto-detects the delimiter, reports a meta object and can run in a worker; csv-parse and Deno can rename columns positionally — is a deliberate omission; the internals page says why. The benchmarks measure this reader against the facility each other runtime ships, from the committed results rather than a number typed here.