fast-csv
fast-csv reads and writes CSV (or any other delimited text) in Node using streams. It is really two packages in a trench coat: the fast-csv you install is a thin meta package that re-exports @fast-csv/parse and @fast-csv/format, pinned to the exact same version. The parsing side turns a byte stream into a stream of row objects or arrays, with hooks to validate and transform each row as it goes past. The formatting side does the reverse, turning objects or arrays into CSV text you can pipe to a file or an HTTP response. Because everything is a stream, memory stays flat whether the file is 10 rows or 10 million, which is the main reason to reach for it over a parse-the-whole-string helper. The whole library is written in TypeScript and ships its own type definitions.
For streaming large CSVs through Node with per-row validation and transforms, fast-csv is a solid pick and the API holds up well. Just go in knowing the release cadence is glacial and that a synchronous one-liner parse is not something this library offers, so small-file work belongs somewhere else.
Use it if
- You are processing a CSV that will not comfortably fit in memory, such as a nightly export or a user upload of unknown size, and you want backpressure handled by Node streams rather than by you
- You want per-row validation with the invalid rows reported separately instead of aborting the run. The .validate() hook plus the 'data-invalid' event gives you a clean split between good rows and rejects
- You need to reshape rows during the read or the write. .transform() runs sync or async per row, so you can look up a foreign key or normalize a date without a second pass
- You are generating a CSV download from a web handler and want to start sending bytes before the query finishes, by piping a database cursor straight into format() and then into the response
- You are dealing with a file that is CSV-shaped but not quite CSV: pipe delimited, tab delimited, comment lines, a preamble to skip, ragged column counts. The option set covers all of those without preprocessing
- You just want to parse a small string or a config-sized file in one line. There is no synchronous parse function here; you set up a stream and wire up events even for three rows. csv-parse ships a sync entry point and papaparse has one call that returns data, and both are less ceremony for that case
- You are running in a browser or an edge runtime. This is built on Node streams and Buffer, so it needs a Node-shaped environment; papaparse is the browser answer and it has worker support on top
- You expect the name to mean it wins benchmarks. The README does not publish any current numbers, and throughput on Node CSV libraries depends heavily on row width, quoting, and whether you use object mode. If speed is the deciding factor, measure against your own file rather than trusting the package name
- You need values typed. Everything comes out of the parser as a string, including numbers, booleans, and empty cells. Coercion is your job in .transform(), and getting empty-string-versus-null right is where most of the bugs live
- You want an actively developed dependency. Look at the release dates: 4.3.6 in December 2020, then nothing until 5.0.0 in January 2024, then 5.0.1, 5.0.2, 5.0.5, and 5.0.7 spread across the two years after. It works and it is maintained enough to keep getting patches, but nobody is pushing it forward, and there are 49 open issues (69 counting PRs)
- You count transitive dependencies. The parse half still pulls lodash.escaperegexp, lodash.groupby, and lodash.uniq as three separate published micro packages, which is a per-package maintenance surface that modern alternatives dropped years ago
Setup reality
npm install fast-csv brings in @fast-csv/parse and @fast-csv/format at the identical pinned version, plus three lodash micro packages under the parse side. TypeScript definitions are bundled, so no @types install. The package is CommonJS with a plain main field and no exports map, which in practice still works from ESM: import { parse, format } from 'fast-csv' resolves the named exports correctly on current Node. The setup pain is not installation, it is the headers option, which behaves in three different ways depending on what you pass. headers: true means the first row of the file is the header row and rows come back as objects. headers: ['id', 'name'] means you are supplying the header names and the first row of the file is treated as data, which silently swallows a real header row as a record. Combining headers: ['id','name'] with renameHeaders: true is the third case, and the one people actually want when they need to override the names in a file that does have a header row. Two more things bite early. Any parse stream without an 'error' listener will take the process down on the first malformed line, and skipLines (drop raw lines before header detection) is a different option from skipRows (drop parsed records after header detection), which are easy to swap without noticing.
Patterns
Read a CSV file into row objectsparse-file-with-headers
const fs = require('node:fs')
const { parse } = require('fast-csv')
fs.createReadStream('users.csv')
.pipe(parse({ headers: true, ignoreEmpty: true, trim: true }))
.on('error', error => console.error(error))
.on('data', row => console.log(row.email))
.on('end', rowCount => console.log(`parsed ${rowCount} rows`))headers: true consumes the first row as names, so every row arrives as an object. The 'end' listener receives the row count, not the rows. Leaving off the 'error' listener means one malformed line takes the whole process down with an unhandled stream error.
Supply or override column namesparse-headerless-file
const { parseFile } = require('fast-csv')
// file has NO header row: name the columns yourself
parseFile('data.csv', { headers: ['id', 'email', 'age'] })
.on('error', console.error)
.on('data', row => console.log(row.id))
// file HAS a header row but the names are bad: replace them
parseFile('data.csv', { headers: ['id', 'email', 'age'], renameHeaders: true })
.on('error', console.error)
.on('data', row => console.log(row.id))Passing an array without renameHeaders: true treats the real header row as a data record, so your first result is { id: 'ID', email: 'Email' }. renameHeaders: true is only valid alongside an array of headers; using it with headers: true throws.
Split valid rows from rejects instead of abortingvalidate-rows
const { parse } = require('fast-csv')
const stream = parse({ headers: true })
.validate(row => Number(row.age) >= 18)
.on('error', console.error)
.on('data', row => accept(row))
.on('data-invalid', (row, rowNumber) => {
rejects.push({ rowNumber, row })
})
.on('end', count => console.log(count, 'rows read'))
readable.pipe(stream)The sync form must return a boolean: any truthy return counts as valid, so the tempting check-or-'reason' idiom silently passes bad rows, and the third 'data-invalid' argument stays null. A reason string only exists in the async callback form, cb(null, false, 'why'), shown in the next pattern. Rows rejected here still count toward the total passed to 'end'. Throwing inside validate() is different: that fires 'error' and stops the stream.
Validate a row against a databaseasync-validate
const stream = parse({ headers: true })
.validate(async (row, cb) => {
try {
const exists = await db.userExists(row.email)
cb(null, !exists, exists ? 'duplicate email' : undefined)
} catch (error) {
cb(error)
}
})
.on('data-invalid', (row, n, reason) => console.warn(n, reason))The async form is detected by arity: a two argument function gets the (error, isValid, reason) callback, a one argument function is treated as sync. Async validation serializes rows one at a time, so a per-row database round trip turns a fast parse into a slow one; batch the lookups first if the file is large.
Reshape and coerce rows during the parsetransform-rows
const stream = parse({ headers: true })
.transform(row => ({
id: Number(row.id),
email: row.email.toLowerCase(),
// empty cell is '' not null, so normalize explicitly
signedUpAt: row.signed_up_at ? new Date(row.signed_up_at) : null,
}))
.on('data', row => console.log(typeof row.id))Every parsed value is a string, including numbers and blanks; a missing cell is '' and Number('') is 0, which is how a blank age column becomes a population of newborns. transform() runs before validate(), so validators see the transformed shape.
Parse a literal CSV stringparse-string-in-tests
const { parseString } = require('fast-csv')
const CSV = 'id,email\n1,ada@example.com\n2,alan@example.com'
const rows = await new Promise((resolve, reject) => {
const out = []
parseString(CSV, { headers: true })
.on('error', reject)
.on('data', row => out.push(row))
.on('end', () => resolve(out))
})There is no synchronous parse in this library, so even a three line string needs this promise wrapper. If most of your parsing looks like this rather than like a file stream, csv-parse/sync is the better fit.
Write rows to a CSV filewrite-csv-to-file
const fs = require('node:fs')
const { format } = require('fast-csv')
const csv = format({ headers: true })
csv.pipe(fs.createWriteStream('out.csv')).on('finish', () => console.log('done'))
csv.write({ id: 1, email: 'ada@example.com' })
csv.write({ id: 2, email: 'alan@example.com' })
csv.end()
// one-shot version for arrays already in memory
const { writeToPath } = require('fast-csv')
writeToPath('out2.csv', [{ id: 1 }], { headers: true })Listen for 'finish' on the file stream, not on the formatter, or you will read the file before the last chunk lands. headers: true takes the column names from the keys of the first object, so any row with an extra key later on gets that column silently dropped.
Stream a CSV download without buffering itstream-csv-http-response
const { pipeline } = require('node:stream/promises')
const { format } = require('fast-csv')
app.get('/export.csv', async (req, res) => {
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.setHeader('Content-Disposition', 'attachment; filename="export.csv"')
const csv = format({ headers: true })
const rows = db.query('SELECT id, email FROM users').stream()
await pipeline(rows, csv, res)
})Because the headers go out before the first row, you cannot switch to a JSON error response if the query fails halfway; the client gets a truncated file. Validate the request and run a cheap count query before you write any header.
Produce a CSV that Excel opens correctlyexcel-friendly-output
const { writeToPath } = require('fast-csv')
writeToPath('report.csv', rows, {
headers: true,
writeBOM: true, // Excel needs the BOM to detect UTF-8
rowDelimiter: '\r\n', // default is '\n'
quoteColumns: true, // quote every field
})Without writeBOM, Excel on Windows renders accented characters and non-Latin scripts as mojibake. quoteColumns can also take an object or array to quote only specific columns, which is what you want when a numeric id must not be quoted but a free-text field must.
Skip a preamble and cap the rows readskip-lines-and-limit
const { parseFile } = require('fast-csv')
parseFile('report.csv', {
skipLines: 3, // drop 3 RAW lines before header detection
headers: true,
skipRows: 10, // then drop the first 10 PARSED records
maxRows: 100, // and stop after 100 records
comment: '#',
})
.on('error', console.error)
.on('data', row => console.log(row))skipLines and skipRows are not the same option and swapping them is a common bug: skipLines runs before the header row is identified, skipRows runs after. maxRows counts records that reach 'data', so rows dropped by validate() do count against it.
Handle rows with the wrong number of columnsragged-columns
const { parse } = require('fast-csv')
parse({
headers: true,
discardUnmappedColumns: true, // extra columns: throw them away
strictColumnHandling: true, // too few columns: send to data-invalid
})
.on('data', row => console.log(row))
.on('data-invalid', row => console.warn('column count mismatch', row))
.on('error', console.error)Without discardUnmappedColumns, a row with more cells than headers throws and kills the stream. Without strictColumnHandling, a short row is accepted with undefined for the missing keys, which is worse than an error because it looks like clean data downstream.
Build a small CSV in memorywrite-to-string
const { writeToString, writeToBuffer } = require('fast-csv')
const csv = await writeToString(
[{ id: 1, email: 'ada@example.com' }],
{ headers: true },
)
const buf = await writeToBuffer(rows, { headers: true, writeBOM: true })Both of these collect the entire output in memory, which throws away the one real advantage of this library. Fine for a test fixture or a small email attachment, wrong for anything user-sized.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| csv-parse | npm | You want both a stream API and a real synchronous parse entry point from a package under continuous development |
| papaparse | npm | You need CSV in the browser, in a Web Worker, or a simple one-call API with automatic type coercion |
| csv-parser | npm | You only ever read CSV, never write it, and want the smallest possible dependency doing exactly that |