CSV Parsers Compared: PapaParse vs fast-csv vs node-csv, and Which Ones Are Dead
"Best CSV parser" is the wrong question. Almost every file people bring to a parser is small enough to hold in memory, and at that size the libraries are interchangeable — they differ on API ergonomics and on how loudly they tell you the file was malformed. The one axis that genuinely separates them is whether you can process the file without loading it.
Download counts are pulled from the npm downloads API on 2026-10-12, 30-day windows at each end of 2024-10-01 → 2026-10-10. Maintenance dates come from the registry the same day. Installs, not users.
The table
| Package | First 30 days | Last 30 days | Change | Last publish |
|---|---|---|---|---|
fast-csv | 8.983M | 79.45M | +784% | 2026-10-05 |
d3-dsv | 15.35M | 101.5M | +561% | 2021-06-05 |
papaparse | 12.3M | 72.63M | +490% | 2026-08-24 |
csv-parse | 23.43M | 90.35M | +286% | 2026-09-25 |
csv-stringify | 15.81M | 45.37M | +187% | 2026-09-25 |
csvtojson | 3.605M | 5.726M | +59% | 2025-10-29 |
csv | 7.283M | 11.22M | +54% | 2026-09-25 |
neat-csv | 0.53M | 0.58M | +9% | 2021-10-18 |
Which ones you should not start with
neat-csv— last published 2021-10-18 and grew +9% over two years. Both signals agree: it is done. Fine to leave installed, wrong choice for new code.d3-dsv— last published 2021-06-05 but +561%. That contradiction resolves when you notice it rides inside the D3 ecosystem: the growth belongs to whichever charting stack is fashionable, not to CSV. Its API is also the weakest here — no real error reporting, because it was built for dataviz where a malformed cell is not an incident.csvtojson— +59%, and the job it does is now three lines over any parser above. The flat line is probably not declining CSV usage; it is that middleman dissolving into the parsers.
The real axis: streaming
fast-csv and csv-parse are stream-first. You can point them at a 12 GB export and memory stays flat:
// fast-csv — transform rows as they arrive, constant memory
import { parse } from 'fast-csv';
import { createReadStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
await pipeline(
createReadStream('huge.csv'),
parse({ headers: true }),
async function* (rows) {
for await (const row of rows) {
if (row.email) await upsert(row); // never all in memory at once
}
},
);
papaparse is in-memory-first. Its step callback makes it look streaming, but the whole input is a string you already hold, so peak memory is roughly your file size plus whatever data array you accumulate — and in a browser, that string is the parse cost plus the result. It is still the right call in a browser tab, where FileReader gives you a string anyway and there is no stream to pipeline against. That is the honest split:
| Situation | Reach for |
|---|---|
| Browser, file the user picked | papaparse |
| Node, file you cannot hold in memory | fast-csv or csv-parse |
| Write as well as read | csv-stringify (same project as csv-parse) |
| One-shot script, size unknown | papaparse, and check the row count after |
What every one of them gets wrong
None of these libraries will tell you that 0123456789 was a phone number. dynamicTyping: true (papaparse) and cast: true (csv-parse) turn it into the number 123456789, and the leading zero is gone in whichever output format you chose. The same rule eats a 19-digit transaction ID into floating-point mush and turns 1-5 into a date in every library that is trying to be helpful about it.
The library that casts aggressively is not better than the one that does not; it just loses your data in a way that is harder to notice three steps downstream. Default to strings and cast deliberately, per column, which is what the best-practices page works through — including this site's own tool, whose exact behaviour on ragged rows and blank lines is measured on About rather than guessed.
What the numbers cannot tell you
- Transitive vs chosen.
csv-parseis a dependency of a lot of CLI tools. Its large base is partly other people's software. - Correctness. No cell in that table is a statement about RFC 4180 edge cases. Two of these packages handle an embedded newline inside a quoted field differently when the closing quote is missing, and neither number tells you which.
- Maintenance, until you looked. That is why the last column exists: it is the only column here that is a fact about the package rather than a rumour about its popularity.