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

JavaScript
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:

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

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

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

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

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

JavaScript
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 outputHeader → objectsType inferenceStreamingErrors
ES-Runtimestring[][]opt-in (header: true)off (cast: false)decoder + encoderthrow
Deno @std/csvarraysopt-in (skipFirstRow)offstream pairthrow
Node.js / Bun— (no built-in)————
Papa Parse 5string[][]opt-in (header: true)off (dynamicTyping: false)step / chunk{ data, errors, meta }
csv-parsestring[][]opt-in (columns)off (cast: false)Node streamsthrow
fast-csvarraysopt-inoffformat + parse streamsthrow
d3-dsvobjectsalwaysoffnothrow

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.

Last updated on
Edit this page