CSV

Green Node + Browser Document
napi1.49–1.88× faster

CSV parsing and serialization via BurntSushi's csv crate.

Version
0.1.0

Install

pnpm add @amigo-labs/csv

Benchmarks

Trend (12 pts)

Benchmark

csv parse - 100 rows, 5 cols

2.61× vs slowest
  • @amigo-labs/csv (parseToJson) napi 12.16K hz · 2.61×
  • @amigo-labs/csv napi 11.95K hz · 2.56×
  • papaparse 8.04K hz · 1.72×
  • csv-parse (sync) 4.66K hz

Benchmark

csv parse - 10,000 rows, 5 cols

4.29× vs slowest
  • @amigo-labs/csv napi 180 hz · 4.29×
  • @amigo-labs/csv (parseToJson) napi 179 hz · 4.26×
  • papaparse 96.9 hz · 2.31×
  • csv-parse (sync) 42.0 hz

Benchmark

csv parse - 100,000 rows, 10 cols

5.47× vs slowest
  • @amigo-labs/csv (parseToJson) napi 10.6 hz · 5.47×
  • @amigo-labs/csv napi 10.5 hz · 5.45×
  • papaparse 5.60 hz · 2.90×
  • csv-parse (sync) 1.93 hz
Performance trend for CSV
12 commits · last 2026-05-28

README

@amigo-labs/csv

npm version npm downloads license

Blazing fast CSV parsing and serialization powered by Rust via NAPI-RS. A native Node.js binding to the csv crate.

Installation

npm install @amigo-labs/csv

Usage

import { parse, parseWithHeaders, stringify } from "@amigo-labs/csv";

// Parse CSV to arrays
const rows = parse(Buffer.from("name,age\nAlice,30\nBob,25"));
// [["name", "age"], ["Alice", "30"], ["Bob", "25"]]

// Parse CSV to objects (using first row as headers)
const objects = parseWithHeaders(Buffer.from("name,age\nAlice,30\nBob,25"));
// [{ name: "Alice", age: "30" }, { name: "Bob", age: "25" }]

// Serialize arrays to CSV
const csv = stringify([["name", "age"], ["Alice", "30"]]);
// "name,age\nAlice,30\n"

API

parse(input, options?): string[][]

Parses CSV into an array of string arrays.

parseWithHeaders(input, options?): Record<string, string>[]

Parses CSV using the first row as column headers, returning an array of objects.

parseToJson(input, options?): string

Parses CSV and returns a flat JSON string. Avoids per-row FFI overhead. Use JSON.parse() on the result.

countRows(input, options?): number

Counts rows without building JS arrays.

stringify(rows, options?): string

Serializes an array of string arrays to CSV.

stringifyObjects(rows, columns?, options?): string

Serializes an array of objects to CSV. Optionally specify column order.

Options

OptionTypeDescription
delimiternumberField delimiter byte (default: ,)
hasHeadersbooleanWhether the first row is a header row (default: true)
quoteCharnumberQuote character byte (default: ")
escapeCharnumberEscape character byte
commentnumberComment prefix byte
flexiblebooleanAllow records with varying field counts
trimFieldsbooleanTrim whitespace from fields

Install for the browser

The same import works in Angular, React, Vite, esbuild, and webpack ≥ 5 — the bundler picks the WASM build via the browser conditional export:

import { parse, parseStr, stringify } from '@amigo-labs/csv'

WASM exposes parseStr (string input) and parse (Uint8Array) — the napi Buffer shape doesn’t exist in the browser. All other entry points (parseWithHeaders, stringify, stringifyObjects, countRows, parseToJson) are present on both sides.

Supported Platforms

PlatformArchitecture
Linuxx64 (glibc), x64 (musl), arm64
macOSx64, arm64
Windowsx64

License

MIT

Perf review

Perf-Review: @amigo-labs/csv

Status: 🟢 Green · Reviewed: 2026-04-21 · Version: 0.1.0

Verdict

Parity Green across all three size buckets — 2.39×–5.22× vs. csv-parse (sync) and 1.52×–1.88× vs. papaparse. The Phase-C fix (commit ecf8408) now routes parse() through parseToJson + JSON.parse, eliminating the earlier Yellow margin on plain parse() — both paths now measure identically. BurntSushi’s csv crate is one of the most mature Rust parser libraries in the ecosystem; FFI overhead at 10k-row scale is <1 % (Buffer in, JSON string out).

Classification rationale

  1. Parser baseline is relatively slow. csv-parse (sync) uses a state machine in JS, papaparse a custom tokenizer; both CPU-bound without SIMD. BurntSushi csv uses memchr-backed field detection plus a zero-copy reader pattern.
  2. The parseToJson path was the Phase-C lever. The original parse() returned Vec<Vec<String>> — 100k rows × 5 cols × marshalling overhead was measurable. Current implementation: Rust builds a JSON string directly, JS does JSON.parse on the output. One FFI crossing instead of 500k.
  3. All three buckets Green. No bimodal problem. Speedup scales with input size (larger = more Rust work relative to FFI transport).

Evidence

Measured speedup (docs/data.json, 2026-04-18)

Scenario@amigo-labs/csvparseToJsoncsv-parsepapaparsevs. csv-parsevs. papaparse
100 rows × 5 cols11 892 Hz12 395 Hz4 968 Hz7 832 Hz2.39×1.52×
10 000 rows × 5 cols177.3 Hz177.7 Hz45.3 Hz97.2 Hz3.91×1.82×
100 000 rows × 10 cols10.37 Hz10.35 Hz1.99 Hz5.51 Hz5.22×1.88×

Realistic use case

ETL / data import — CSV uploads from user forms, log-file analysis, spreadsheet-export pipelines. Median workload: 10 KB – 10 MB CSV, 10–100k rows. One parse call per file, deterministic output shape. Second use case: CLI tooling (csv util pipes), where inputs are smaller but call frequency is higher — the 100-row bucket matters there.

Benchmark gaps

  • stringify path not benched (row array → CSV string). Phase-C equivalent of the parseToJson lever not verified. Catch up before v0.2.
  • parseWithHeaders not separately benched. The npm csv-parse convention {columns: true} is the default production use case. We have the API, no measurement.
  • Edge cases (CRLF/LF mix, quoted delimiter, UTF-8 BOM) not as bench scenario — parity covered via __conformance__/upstream.spec.ts, but a perf-only split would be useful.

API surface

#[napi] fn parse(input: Buffer, options: Option<CsvOptions>) -> Result<Vec<Vec<String>>>
#[napi(js_name = "parseWithHeaders")] fn parse_with_headers(input: Buffer, ...) -> Result<Vec<HashMap<String, String>>>
#[napi] fn stringify(rows: Vec<Vec<String>>, options: ...) -> Result<String>
#[napi(js_name = "stringifyObjects")] fn stringify_objects(rows: Vec<HashMap<String, String>>, columns: ..., options: ...) -> Result<String>
#[napi(js_name = "countRows")] fn count_rows(input: Buffer, options: ...) -> Result<u32>
#[napi(js_name = "parseToJson")] fn parse_to_json(input: Buffer, options: ...) -> Result<String>
  • Buffer input throughout (zero-copy transport).
  • parse() is internally identical to parseToJson + JSON.parse (commit ecf8408).
  • countRows() is the shortcut for row counting without array construction — pure Rust, zero array FFI.
  • parseToJson() is the exposed hot path — the user can JSON.parse and stream themselves.

Bundle / binary size

BurntSushi csv crate without the serde feature. Likely 300–500 KB per target — small. docs/data.json’s sizes field has the exact number.

FFI-overhead baseline

  • 100k-row bucket: input buffer ~5 MB via buffer handle ~180 ns transport. Output JSON string ~8 MB via UTF-8→UTF-16 conversion at 0.35 ns/byte = ~2.8 ms. On ~100 ms of Rust parse = 2.8 % FFI share. Tolerable.
  • 100-row bucket: FFI ~30 µs on ~80 µs Rust = 27 %. A buffer-output variant would be a theoretical lever there, but the current 2.39× speedup is already Green.

Phase-C optimization checklist

#LeverApplicableNotes
C.1Input-type minimisation✅ already doneBuffer throughout, zero-copy
C.2Output-type minimisation (string JSON instead of Vec<Vec>)✅ already doneCommit ecf8408
C.3Batch API❌ not applicableOne-call-per-file is the idiom
C.4Stateful API (CsvParser class)🟡 marginalIf users repeatedly parse with identical options, small build-opts wins. Sub-percent.
C.5Parallelisation❌ not applicableCSV is a sequential parse
C.6Algorithm swap (qsv-style simd-csv)🟡 potentialThe csv-async crate has SIMD experiments. If 10× vs. BurntSushi is measurable, sprint-worthy. Currently 5.22× at 100k — enough headroom.
C.7Allocator tuning❌ not applicable
C.8Bundle size✅ already doneWorkspace profile

Action plan

Keep-as-is. Green across every bucket, Phase-C lever already pulled.

Maintenance:

  1. Add a stringify bench — symmetric path to parse.
  2. parseWithHeaders bench — the primary production use case, deserves its own measurement.
  3. SIMD-CSV spike (Phase-C.6) only as fast-follow if a 10× upgrade is portfolio-political. No pressure right now.

References

  • Crate: crates/csv
  • Bench: crates/csv/__bench__/index.bench.ts
  • Lib: crates/csv/src/lib.rs
  • Cargo: crates/csv/Cargo.toml
  • Phase-C commit: ecf8408 (parse()parseToJson + JSON.parse)
  • docs/packages.json speedup: "1.52–1.88× faster" (vs. papaparse — conservative figure)