# elide:csv

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

```js
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

```text
# users.csv
id,name,joined
1,ada,2024
2,lin,2025
```

```js
// 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:

```text
1 ada
2 lin
```

## Parsing

- `parse(input, options?)` reads a string, `Uint8Array`, `ArrayBuffer` or `DataView` (UTF-8) and returns
  a table.
- `parseFile(path, options?)` reads a file path or `file:` URL. Call `table.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 a `ReadableStream` of bytes. See
  [Streaming](#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 ±(2<sup>53</sup>−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.

```js
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()`:

```js
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:

```js
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:

```ts
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`](/docs/api-yaml) writes them directly:

```js
// 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:

```yaml
- id: 1
  name: ada
- id: 2
  name: lin
```
