@nds-stack/bun-csv
v0.1.0-alpha.0
Published
Bun-native CSV parser and generator — zero dependencies, streaming via Bun.file()
Maintainers
Readme
@nds-stack/bun-csv
Bun-native CSV parser and generator — zero dependencies, streaming via
Bun.file().
How It Works
bun-csv uses a single-pass, character-by-character state machine parser that processes CSV without intermediate allocations. The parser tracks field boundaries, quoted sections, and escaped characters in one pass — no regex backtracking, no array-of-arrays overhead.
For streaming, parseStream() uses Bun.file().stream() to read chunks via ReadableStream, decodes bytes to text progressively, and yields parsed row batches as they arrive.
The stringifier builds rows by scanning fields for characters that require quoting (delimiter, quote, newline) and applies quote escaping — then joins fields with the delimiter in a single pass.
Installation
bun add @nds-stack/bun-csvAPI
parse(csv: string, options?: ParseOptions): ParseResult
Parses a CSV string into structured data.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| csv | string | — | CSV content |
| options.delimiter | string | "," | Field delimiter |
| options.quoteChar | string | '"' | Quote character |
| options.escapeChar | string | '"' | Escape character (doubled) |
| options.hasHeader | boolean | true | First row is header |
| options.skipEmptyLines | boolean | true | Skip blank lines |
| options.trim | boolean | false | Trim whitespace from fields |
| options.cast | boolean | false | Auto-cast to number/boolean |
Returns: ParseResult<T>
interface ParseResult<T = Record<string, string>> {
data: T[]
headers: string[]
rows: number
}stringify(data: Record<string, unknown>[], options?: StringifyOptions): string
Converts an array of objects into a CSV string.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| data | Record<string, unknown>[] | — | Array of objects |
| options.delimiter | string | "," | Field delimiter |
| options.quoteChar | string | '"' | Quote character |
| options.escapeChar | string | '"' | Escape character (doubled) |
| options.header | boolean | true | Include header row |
| options.trailingNewline | boolean | true | Append trailing newline |
parseStream(file: BunFile, options?: ParseOptions): AsyncGenerator<StreamChunk<T>>
Streams a CSV file using Bun.file().stream(), yielding parsed row chunks.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| file | BunFile | — | A BunFile instance |
| options.batchSize | number | 100 | Rows per yielded chunk |
Yields: StreamChunk<T>
interface StreamChunk<T = Record<string, string>> {
data: T[]
chunkIndex: number
byteOffset: number
}Error Handling
| Scenario | Behavior |
|----------|----------|
| Malformed quotes | Parser continues; unclosed quotes consume rest of field |
| Empty input | Returns { data: [], headers: [], rows: 0 } |
| Mismatched column count | Missing fields become empty string; extra fields ignored |
| Empty data array for stringify | Returns empty string "" |
| Missing keys in row | Treated as empty string |
No exceptions thrown for formatting issues — the parser is lenient by design, similar to papaparse.
Limitations
- No streaming write —
stringify()is synchronous, returns full string. For large datasets, consider chunked writes. - No type schema — Unlike
csv-parsewithcastfunctions, bun-csv provides simple auto-cast only (cast: true). For complex type coercion, post-process the result. - No web worker — CSV parsing is synchronous. For very large files (>100MB), use
parseStream()to process incrementally. - No auto-delimiter detection — You must specify the delimiter explicitly if not comma.
- No comment lines — Lines starting with
#are not skipped.
Multi-Instance / Cross-Boundary
Since bun-csv is a stateless parser (no internal state), it is safe to use across multiple instances, workers, or processes:
// Worker 1
const result1 = parse(csv1);
// Worker 2 (same import)
const result2 = parse(csv2);
// Streaming in separate processes
for await (const chunk of parseStream(file)) { ... }parseStream() creates a fresh ReadableStream reader per call, so concurrent streams on the same file work independently.
Customization Guide
Custom Delimiter (TSV)
import { parse, stringify } from "@nds-stack/bun-csv";
const tsv = parse(data, { delimiter: "\t" });
const output = stringify(tsv.data as Record<string, unknown>[], { delimiter: "\t" });Headerless Mode
const result = parse(csv, { hasHeader: false });
// headers: ["column0", "column1", ...]Auto-Type Casting
const result = parse(csv, { cast: true });
// "30" → 30, "true" → true, "false" → falseStreaming Large File
import { parseStream } from "@nds-stack/bun-csv";
const file = Bun.file("./massive.csv");
for await (const chunk of parseStream(file)) {
await processBatch(chunk.data);
}Extending Parser
import { parse } from "@nds-stack/bun-csv";
function parseWithValidation(csv: string) {
const result = parse(csv);
if (result.rows === 0) throw new Error("Empty CSV");
return result;
}Comparison Table
| Feature | bun-csv | csv-parse | papaparse |
|---------|---------|-----------|-----------|
| Dependencies | 0 | 8 (sub-deps) | 0 |
| Bun-native | Yes | No (Node.js) | No (browser) |
| Streaming | Yes (Bun.file()) | Yes (Node stream) | Yes (File API) |
| Auto-cast | Yes | Yes (via cast fn) | Yes (dynamic) |
| TypeScript | Strict | Loose | Loose |
| Bundle size | ~3 KB | ~45 KB | ~30 KB |
| Tree-shakeable | Yes | Partial | No |
Benchmarks
Results on Bun 1.3.14 (Windows), 500 iterations, 100 rows CSV with 9 columns:
Library Throughput vs Base
------------------------------------------------------------------------
@nds-stack/bun-csv (parse) 8,120 ops/s +534.4%
csv-parse/sync 1,280 ops/s 0.0%
papaparse 3,654 ops/s +185.5%
@nds-stack/bun-csv (stringify) 9,438 ops/s +637.3%bun-csv parse is 5.3× faster than csv-parse and 2.2× faster than papaparse.
Results vary by machine. Run bun run bench in the module directory.
Real-World Example
import { parse, stringify, parseStream } from "@nds-stack/bun-csv";
import { write } from "bun";
// Parse downloaded CSV
const file = Bun.file("./users.csv");
const text = await file.text();
const users = parse(text, { cast: true });
// Filter + transform
const activeUsers = users.data.filter(u => u.active === true);
const transformed = activeUsers.map(u => ({
name: u.name,
email: u.email,
age: u.age,
}));
// Write back as CSV
const output = stringify(transformed);
await write("./active-users.csv", output);
// Or stream directly from URL
const response = await fetch("https://example.com/report.csv");
const text = await response.text();
const data = parse(text);
console.log(`Parsed ${data.rows} rows`);License: MIT
