npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@culvert/csv

v0.1.0

Published

Streaming CSV parser and stringifier. Bounded memory. RFC 4180 plus the real-world dialects. Strict-by-default malformed-input handling.

Downloads

93

Readme

@culvert/csv

Streaming CSV parser and stringifier. Bounded memory. RFC 4180 plus the real-world dialects. Works in Node, Deno, Bun, Cloudflare Workers, and browsers.

Install

npm install @culvert/csv

Parsing

csvParse is a Transform<Uint8Array, Row> — a peer of any other pipeline stage, not a special parsing layer. Row-level operators like filter and map are five-line async generators you write yourself (@culvert/stream deliberately doesn't ship them):

import { pipe, collect } from "@culvert/stream";
import { csvParse } from "@culvert/csv";

async function* activeOnly(rows: AsyncIterable<Record<string, string>>) {
  for await (const row of rows) {
    if (row.status === "active") yield row;
  }
}

const active = await pipe(
  response.body!,                      // Source<Uint8Array>
  csvParse({ headers: true }),         // Transform<Uint8Array, Record<string, string>>
  activeOnly,
  collect(),
);

Memory is bounded by the longest single row. Quoted fields may contain newlines and escaped quotes ("") — the tokenizer is character-driven, so the row count is right even when line-driven parsers get it wrong.

Headers

  • headers: false (default) — rows are string[], positional.
  • headers: true — the first row becomes the keys; rows are Record<string, string>.
  • headers: string[] — you supply the keys; no row is consumed.

Blank lines are never consumed as the header — they yield empty records (or are dropped with skipEmptyLines). Duplicate header names throw in strict mode, because record projection would silently drop every earlier duplicate column; use onMalformed: "permissive" for last-wins, or supply headers: string[] to override.

Every value is a string

CSV has no type system. dynamicTyping-style coercion silently turns "01234" zip codes into 1234, so we don't ship it. The generic parameter types the row's keys; coercion belongs in your own transform, where it's visible:

interface UserRow { id: string; age: string }

async function* coerce(rows: AsyncIterable<UserRow>) {
  for await (const row of rows) {
    yield { ...row, age: parseInt(row.age, 10) };
  }
}

pipe(source, csvParse<UserRow>({ headers: ["id", "age"] }), coerce, sink);

Malformed input

Strict default + named modes + function escape hatch — the same recipe as tar's pathPolicy:

csvParse({ onMalformed: "strict" });      // throw CsvSyntaxError (default)
csvParse({ onMalformed: "permissive" });  // emit a best-effort row
csvParse({
  onMalformed: (err, rawLine) => {
    log(err, rawLine);
    return null;                          // skip; or return a substitute row
  },
});

Covers unbalanced quotes, data after a closing quote, and row-length mismatches against the header set. When the header row itself is malformed (with headers: true), a string[] substitute becomes the header, and null skips the line — the next row is consumed as the header instead. A record substitute in header position throws.

Dialect options

csvParse({
  delimiter: ";",        // default ","
  quote: "'",            // default '"'
  newline: "auto",       // default; accepts \r\n, \n, and \r anywhere
  comment: "#",          // skip lines starting with this; off by default
  trim: true,            // trim unquoted fields; default false
  skipEmptyLines: true,  // default false
});

With newline: "auto" the parser accepts all three terminators mixed in one file. An explicit terminator is exact — other newline characters become field data.

Stringifying

import { pipe, writeTo } from "@culvert/stream";
import { csvStringify } from "@culvert/csv";

await pipe(
  database.query("SELECT id, name FROM users"),  // Source<Record<string, string>>
  csvStringify({ headers: true }),               // Transform<Row, Uint8Array>
  writeTo(httpResponse),
);

Output starts with the first row; the full result set is never held in memory. Defaults are conservative where the parser is liberal: \r\n terminators (RFC 4180) and minimal quoting — a field is quoted only when it contains the delimiter, the quote character, or a newline. quoting: "all" quotes everything.

With headers: true the header row is the first record's keys; later records are projected onto that key set (missing keys emit empty strings, extra keys are dropped). headers: string[] supplies the key set up front.

A lone empty field is always quoted (""), because a bare newline would parse back as a zero-field row — round-trips stay honest. One caveat minimal quoting can't fix: a field starting with a comment character is emitted bare, so if the consumer parses with a comment dialect, that row reads as a comment. Use quoting: "all" when emitting for comment-aware consumers.

Errors

  • CsvSyntaxError — malformed input: unbalanced quotes, invalid UTF-8, header/row length mismatch.
  • CsvWriteError — the stringifier got a row whose shape doesn't match its header mode.
  • CsvAbortError — an AbortSignal fired.

Same names, same semantics as @culvert/zip, @culvert/tar, and @culvert/gzip.

What's not included

  • Type coercion. Values are strings; coerce in a map operator.
  • Encoding detection. UTF-8 only (a leading BOM is stripped). Invalid UTF-8 throws CsvSyntaxError.
  • Dialect sniffing. You know your input; say what it is.
  • Header renaming. headers: string[] overrides keys; renaming is a map on your side.
  • quoting: "none". It would have to corrupt data or throw at runtime — both worse than understanding quoting.

Related packages

License

MIT