elide:csv parses, streams and writes CSV. Rows are keyed by header name, and declared columns are
converted to numbers, bigints and booleans while parsing. There is nothing to install.
import { parse, stringify } from "elide:csv";
const users = parse("id,name,joined\n1,ada,2024\n2,lin,2025\n", {
columns: { id: "integer", name: "string" },
});
users.length; // 2
users[0].id; // 1
users[1].name; // "lin"
users[0].joined; // undefined: unlisted columns are skipped
stringify(users.toArray()); // "id,name\r\n1,ada\r\n2,lin\r\n"
Import it as elide:csv. A bare "csv" specifier resolves to the npm package of that name.
Read a file
# users.csv
id,name,joined
1,ada,2024
2,lin,2025
// users.mjs
import { parseFile } from "elide:csv";
const users = parseFile("users.csv", { columns: { id: "integer", name: "string" } });
try {
for (const user of users) console.log(user.id, user.name);
} finally {
users.close();
}
elide run users.mjs prints:
1 ada
2 lin
Parsing
parse(input, options?)reads a string,Uint8Array,ArrayBufferorDataView(UTF-8) and returns a table.parseFile(path, options?)reads a file path orfile:URL. Calltable.close()when done: until then the table holds the file, and only the code that opened it can read it.open(source, options?)streams a path,file:URL, bytes, or aReadableStreamof bytes. See Streaming.
A table is array-like: table.length, table[i], table.at(i) (negative counts from the end) and
for...of work as they do on arrays. table.columns lists the column names, table.toArray() copies
every row into a plain object, and table.close() releases it; reading a closed table throws. Each
index read returns a new row object, so table[0] !== table[0].
A row is an object keyed by column name: Object.keys(row) and JSON.stringify(row) see the names
only. Without a header (header: false), columns are named "0", "1", and so on, so row[0] works.
Typed columns
Without columns, every cell is a string. With columns, the listed columns are converted and the
rest of the header is skipped; a listed column missing from the header throws.
| Type | Accepts | Reads as |
|---|---|---|
"string" | any text | string |
"number" | decimal or exponent notation | number |
"integer" | digits within ±(253−1) | number |
"bigint" | digits within 64-bit range | bigint |
"boolean" | true or false, any case | boolean |
Give a column as a type name, or as { type, nullable, optional }. A nullable column reads an
empty unquoted field (or a nullValues token) as null. An optional column may be absent from the
header or from a short row; its key is then missing from the row.
parse("id,email\n1,\n", {
columns: { id: "bigint", email: { type: "string", nullable: true } },
})[0]; // { id: 1n, email: null }
Options
| Option | Default | Meaning |
|---|---|---|
delimiter | "," | Field separator |
quote | '"' | Quote character; null reads quotes as text |
escape | none | Escape character inside fields |
comment | none | Skip lines starting with this character |
header | true | Take names from the first record, number the columns (false), or use these names (string[]) |
skipHeader | false | With header names, skip the first record |
trim | false | Strip spaces and tabs around fields |
skipEmptyLines | true | Skip empty lines |
flexible | false | Accept rows shorter than the header; their missing cells are absent |
nullValues | none | Values that read as null |
maxRows | all | Stop after this many rows |
duplicateHeaders | "error" | Repeated header names: "error", "allowEmpty" (blank names only) or "allow" |
format | "rfc4180" | "excel", "tdf", "mysql" or "postgresql" |
limits | see below | Resource bounds |
strict | see below | RFC 4180 strictness |
Reads are strict by default: a quote inside an unquoted field, text after a closing quote, an
unterminated quote, or a row wider than the header throws. escape, comment, a multi-character
delimiter, skipEmptyLines: false and every format other than "rfc4180" read in the more lenient
style of those formats instead; combining one with strict: true throws a TypeError.
limits bounds maxBytes (input size, 64 MiB), maxRecordBytes (one record, 1 MiB), maxColumns
(4096) and maxRows (rows per table, about a million).
Streaming
open() returns a reader. Iterate rows with for await, or whole tables with batches():
import { open } from "elide:csv";
let total = 0n;
for await (const row of open("orders.csv", { columns: { amount: "bigint" } })) {
total += row.amount;
}
for await (const table of open("orders.csv", { batchBytes: 1 << 20 }).batches()) {
console.log(table.length, table.columns);
}
When it runs out of rows, the reader reads the next batch, about batchBytes (default 1 MiB) of input,
in the background. reader.columns is null until the header has been read. Breaking out of the loop,
await reader.close() and await using reader = open(...) all stop reading and release the source.
Bytes passed to open() are copied first.
Writing
stringify(rows, options?) writes objects by key, arrays by position, or a parsed table:
import { stringify } from "elide:csv";
stringify([{ id: 1, note: "a, b" }, { id: 2, note: null }]);
// 'id,note\r\n1,"a, b"\r\n2,\r\n'
stringify([[1, 2n, true]], { recordSeparator: "\n" }); // '1,2,true\n'
| Option | Default | Meaning |
|---|---|---|
header | on for objects and tables | Write a header line, or these names |
columns | first object’s keys | Keys (or table columns) to write, in order |
delimiter, quote, escape | ",", '"', none | As for parsing |
quoteMode | "minimal" | "all", "allNonNull", "nonNumeric" (numbers bare) or "none" |
recordSeparator | "\r\n" | Line ending |
nullValue | "" | Written for null and undefined |
Cells must be strings, numbers, bigints, booleans, null or undefined; anything else throws a
TypeError.
Errors
Failures throw errors with a code:
| Code | Error | Cause |
|---|---|---|
ERR_CSV_SYNTAX | SyntaxError | Malformed CSV, invalid UTF-8, or a bad header; the message names the record and byte |
ERR_CSV_CONTRACT | Error | A cell that does not fit its column, or a listed column missing from the header |
ERR_CSV_LIMIT | Error | A limits bound was exceeded |
ERR_CSV_CLOSED | Error | A closed table or reader was read |
ERR_CSV_IO | Error | The stream failed while reading |
ERR_INVALID_ARG_TYPE, ERR_INVALID_ARG_VALUE | TypeError | An invalid argument or option |
ERR_OUT_OF_RANGE | RangeError | A numeric option out of range |
File errors (ENOENT, EACCES, EISDIR) match node:fs. Messages never include cell contents.
TypeScript
@elide-dev/types declares the module. Rows are typed from columns, or checked against a row type you
supply:
import { parse } from "elide:csv";
interface User {
id: number;
email: string | null;
}
const users = parse<User>(text, {
columns: { id: "integer", email: { type: "string", nullable: true } },
});
With elide:yaml
Rows are plain data, so elide:yaml writes them directly:
// to-yaml.mjs
import { parseFile } from "elide:csv";
import { stringify } from "elide:yaml";
const users = parseFile("users.csv", { columns: { id: "integer", name: "string" } });
try {
process.stdout.write(stringify(users.toArray()));
} finally {
users.close();
}
elide run to-yaml.mjs prints:
- id: 1
name: ada
- id: 2
name: lin