csv-parse
csv-parse turns CSV text into arrays or objects. It is one package from the four-part CSV project at csv.js.org, alongside csv-stringify, csv-generate, and stream-transform. The core is a byte-level state machine wrapped in a Node stream.Transform, which means you can pipe a multi-gigabyte file through it and never hold more than one record in memory. On top of that sit two convenience shapes: a callback form that collects everything into an array, and a fully synchronous form exported from csv-parse/sync for when the input is already a string and small. It has been released since 2010, has no runtime dependencies, and its option list is long enough to handle almost every malformed export you will be handed: custom delimiters and quotes, comments, BOMs, ragged rows, value casting, byte offsets, and per-record hooks.
The parser to reach for when the CSV comes from somewhere you do not control, because the option list covers the failures other parsers choke on and the streaming path holds up on files bigger than memory. Something lighter is a better fit for small, clean, known-good input.
Use it if
- You are parsing files larger than memory and want real streaming with backpressure rather than a helper that buffers the whole thing and then hands you an array
- Your input is not clean CSV: semicolon or tab delimiters, comment lines, a UTF-8 BOM, rows with the wrong column count, or quotes that only sometimes escape correctly. There is an option for each of those
- You want zero dependencies in a data pipeline, which csv-parse has, at about 7.9 kB gzipped
- You need per-record control: on_record to transform or drop rows as they stream, cast to convert values, info to get line numbers and byte offsets for error reporting, and on_skip to log bad rows instead of failing
- You want speed above all: the state machine is careful rather than fast, and benchmark-oriented parsers such as papaparse and csv-parser will out-throughput it on clean input. Correctness on messy input is what you are paying for
- Your CSV is small and clean: for a 200-line config file, splitting on commas or using the sync export of a smaller library is less machinery. csv-parse gives you 37 options you will never touch
- You need to write CSV as well: this package only reads. Output means adding csv-stringify, and the umbrella csv package pulls in all four projects
- You want typed records for free: the TypeScript generic on parse describes the shape you claim rows have, it does not validate them, so a header change gives you undefined fields that type-check fine
- You are running in a browser: there are dedicated ./browser/esm entry points because the default build imports node:stream, so a bundler pointed at the main entry drags a stream shim in
- You need error handling to be simple: parse errors surface as an 'error' event on the stream in the streaming API, as a thrown CsvError in the sync API, and as the first callback argument in the callback API, and each shape stops at the first bad row unless you set skip_records_with_error
Setup reality
npm install csv-parse is one package with no dependencies and no build step. The package publishes both ESM and CommonJS through the exports map, so import and require both resolve, and there are separate ./sync, ./stream, and ./browser/esm entry points. The trap is that /sync is a distinct import path, not an option, and people spend real time wondering why the default export returns a stream instead of rows. Version numbering deserves a note: the changelog records that 7.0.0 was published by mistake and contains no breaking changes over 6.x, so a major bump that reads as scary is not. Node's stream ergonomics are the other cost. In streaming mode you attach 'readable', 'error', and 'end' listeners yourself, and an unhandled 'error' event crashes the process, so wire the error listener before writing anything. If you would rather use for await, the parser is an async iterable and try/catch works normally. Finally, columns: true reads the first row as headers, which silently produces one fewer record than you expect if the file has no header line.
Patterns
Parse a string you already have in memorysync-parse-string
import { parse } from 'csv-parse/sync'
const records = parse(text, {
columns: true,
skip_empty_lines: true,
trim: true,
})
// records: [{ id: '1', name: 'ada' }, ...]csv-parse/sync is a separate entry point, not an option on the main one. It buffers everything and throws a CsvError on the first bad row, so keep it for input you know is small.
Stream a large file record by recordstream-a-file
import fs from 'node:fs'
import { parse } from 'csv-parse'
const parser = fs.createReadStream('events.csv').pipe(
parse({ columns: true, skip_empty_lines: true }),
)
for await (const record of parser) {
await handle(record)
}The parser is an async iterable, so for await gives you backpressure and lets a normal try/catch see parse errors. Awaiting inside the loop pauses reading, which is exactly what you want when the consumer is slower than the disk.
Collect every record with a callbackcallback-collect-all
import { parse } from 'csv-parse'
parse(input, { columns: true }, (err, records, info) => {
if (err) return done(err)
console.log(records.length, 'records from', info.lines, 'lines')
})The third callback argument carries the parse info: lines, records, bytes, invalid_field_length and more. This form holds the whole result in memory, so it is the convenience shape rather than the scalable one.
Handle a file that is not comma separatedcustom-delimiter-and-quotes
const records = parse(text, {
delimiter: ';',
quote: '"',
escape: '\\',
record_delimiter: ['\r\n', '\n'],
bom: true,
})
// or let it work the delimiter out from a sample
const guessed = parse(text, { delimiter_auto: true, columns: true })delimiter accepts an array when a file mixes separators. bom: true strips the UTF-8 byte order mark, and without it your first header key comes back with an invisible prefix and every lookup on it returns undefined.
Convert strings to numbers, booleans, and datescast-values
const records = parse(text, {
columns: true,
cast: (value, context) => {
if (context.header) return value
if (context.column === 'amount') return Number(value)
if (context.column === 'active') return value === 'true'
return value
},
cast_date: true,
})cast runs for header cells too unless you check context.header, which is the usual cause of headers turning into NaN. context also carries index, lines, and quoting, so you can treat a quoted empty string differently from an unquoted one.
Drop or reshape records as they streamfilter-and-transform-rows
const parser = parse({
columns: true,
on_record: (record, { lines }) => {
if (!record.email) return null
return { line: lines, email: record.email.toLowerCase() }
},
})Returning null or undefined from on_record drops the record without emitting it, which keeps filtering out of your consumer loop. The hook runs once per record inside the parser, so anything slow in here stalls the stream.
Keep going when some rows are malformedtolerate-bad-rows
const parser = parse({
columns: true,
relax_column_count: true,
relax_quotes: true,
skip_records_with_error: true,
on_skip: (err, raw) => {
console.warn('skipped', err.code, raw)
},
})Without skip_records_with_error, the first ragged row ends the parse. relax_column_count_less and relax_column_count_more let you accept short rows but still reject long ones, which is usually what you actually want.
Read only part of a fileslice-input
// skip a two-line preamble, read 1000 data records
const records = parse(text, {
from_line: 3,
to: 1000,
columns: true,
})from_line and to_line count physical lines including the header, while from and to count records after parsing. Mixing them up on a file with quoted newlines gives you the wrong window, because one quoted field can span several lines.
Catch parse errors on the stream APIerror-handling-stream
import { parse, CsvError } from 'csv-parse'
const parser = parse({ columns: true })
parser.on('error', (err) => {
if (err instanceof CsvError) {
console.error(err.code, 'at line', err.lines)
}
response.destroy(err)
})
parser.on('readable', () => {
let record
while ((record = parser.read()) !== null) rows.push(record)
})Attach the error listener before you write anything: an unhandled 'error' event on a stream takes the process down. CsvError carries a stable code such as CSV_RECORD_INCONSISTENT_FIELDS_LENGTH, which is what you should branch on rather than the message.
Type the records in TypeScripttyped-records
import { parse } from 'csv-parse/sync'
type Row = { id: string; email: string; amount: string }
const rows = parse(text, { columns: true }) as Array<Row>
// or with the generic on the streaming parser
// const parser = parse<Row>({ columns: true })The generic is an assertion about the file, not a validation of it. A renamed column still type-checks and shows up as undefined at runtime, so validate the header row or run the records through a schema library.
Ignore comment lines and blank rowscomments-and-empty-lines
const records = parse(text, {
columns: true,
comment: '#',
comment_no_infix: true,
skip_empty_lines: true,
skip_records_with_empty_values: true,
})By default the comment character is honoured anywhere in a line, so a value containing a hash gets truncated. comment_no_infix: true restricts it to the start of a line, which is almost always the behaviour you meant.
Get line numbers and byte offsets per recordrecord-info-offsets
const parser = parse({ columns: true, info: true })
for await (const { record, info } of parser) {
if (!isValid(record)) {
console.error(`bad row at line ${info.lines}, byte ${info.bytes}`)
}
}info: true changes the emitted shape from the record to a { record, info } wrapper, so every downstream consumer has to be updated at the same time. The offsets are what let you point a user at the exact line of a 4 GB upload.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| papaparse | npm | You need the same parser in the browser and in Node, want worker-thread parsing, or want auto-detection of the delimiter with a friendlier API |
| fast-csv | npm | You want parsing and formatting from one package with a stream API and first-party TypeScript types |
| csv-parser | npm | You just want a stream of objects from a well-formed file as fast as possible and none of the recovery options matter |
| csv-stringify | npm | You are writing CSV rather than reading it, and want the same option vocabulary as csv-parse from the same project |