papaparse
Papa Parse turns CSV text into JavaScript arrays or objects and back again. You hand it a string, a File from an input element, a URL to download, or a Node readable stream, and it gives you rows plus a metadata object describing what it found. The parser follows RFC 4180, so quoted fields containing commas and embedded newlines come out correct, which is where most hand-rolled split(',') code falls apart. It has zero dependencies, weighs under 7 KB gzipped, can auto-detect the delimiter, can run in a Web Worker so a 200 MB file does not freeze the tab, and can stream row by row instead of building one enormous array in memory. Papa.unparse goes the other direction and writes CSV from your objects.
For CSV in a browser, Papa Parse is still the right answer: correct on quoting, tiny, dependency-free, and the only option with worker and streaming support that just works. On the server it is merely adequate, and csv-parse or fast-csv fit Node's stream model better.
Use it if
- You accept CSV uploads in the browser and need to parse them client-side, with a progress indicator and no server round trip; this is the case Papa Parse was built for and nothing else does it as well
- Your input is real-world messy CSV: quoted fields with commas and newlines inside them, unknown delimiter (comma, semicolon, tab, pipe), a BOM at the start, inconsistent line endings
- The files are big enough that reading everything into memory is a problem, and you want a step or chunk callback that processes rows as they arrive, with pause, resume, and abort
- You also need to generate CSV for download, and would rather use Papa.unparse than write your own quoting and escaping rules
- You are server-side only. In Node the worker, download, withCredentials, LocalChunkSize, and RemoteChunkSize options all do nothing, and in NODE_STREAM_INPUT mode step and complete are unavailable too. csv-parse and fast-csv are built for Node streams and give you a better API there
- dynamicTyping will corrupt your data and you have not thought about it: it converts "007" to 7, turns long numeric IDs into floats that lose precision past 2^53, and reads "NA" or "null" as strings while turning "true" into a boolean. For anything financial or identity-related, leave it off and coerce fields deliberately
- You want TypeScript types in the box. Papa Parse ships plain JavaScript; types live in the separate @types/papaparse package, so an npm install alone leaves you with an untyped import
- You need promises. Every asynchronous path is callback-based (complete, error, step, chunk) with no promise or async iterator API, so it does not compose with async/await without you writing the wrapper
- Your files reliably exceed what a browser tab can hold and you need real backpressure or column-typed output. At that point stream it to the server, or use a columnar reader; Papa Parse's worker mode reduces jank but still copies every row across the worker boundary
Setup reality
npm install papaparse is genuinely painless: no dependencies, no peer dependencies, no build step, and it works from a script tag or a bundler. Two things surprise people. First, types are not included, so TypeScript users need npm install --save-dev @types/papaparse as a second step. Second, the package is browser-first: the default export targets the browser build, and the Node behaviors are documented as a list of options that quietly stop working rather than as a separate Node API. Worker mode also has a quirk that bites bundler users, because Papa builds the worker from its own script URL, and some bundler configurations make that URL unreachable, in which case parsing silently falls back or fails depending on setup. Everything else is config-object tuning against a single long options table.
Patterns
Parse a CSV string into objectsparse-string-with-header
import Papa from "papaparse";
const csv = "name,age\nAda,36\nGrace,45";
const result = Papa.parse(csv, {
header: true,
skipEmptyLines: true,
});
console.log(result.data); // [{ name: "Ada", age: "36" }, ...]
console.log(result.errors); // []
console.log(result.meta.fields); // ["name", "age"]Parsing a string is synchronous and returns the result directly. Values stay strings unless you turn on dynamicTyping. skipEmptyLines: 'greedy' also drops lines that contain only delimiters and whitespace.
Parse a file chosen in the browserparse-file-upload
const input = document.querySelector("input[type=file]");
input.addEventListener("change", () => {
Papa.parse(input.files[0], {
header: true,
complete: (results) => {
console.log(results.data.length, "rows");
},
error: (err) => console.error(err),
});
});Passing a File makes parsing asynchronous, so the return value is useless and you must use the complete callback. Add encoding: 'ISO-8859-1' when users upload files exported from older spreadsheet tools.
Fetch and parse a CSV by URLdownload-remote-csv
Papa.parse("https://example.com/data.csv", {
download: true,
header: true,
downloadRequestHeaders: { Authorization: "Bearer " + token },
complete: (results) => render(results.data),
});download: true only works in the browser and is subject to CORS like any other request. In Node it is ignored, so fetch the body yourself and pass the string or stream.
Handle rows as they arrive instead of all at oncestream-row-by-row
Papa.parse(file, {
header: true,
step: (row, parser) => {
if (!row.data.email) {
parser.abort();
return;
}
insertIntoDb(row.data);
},
complete: () => console.log("done"),
});With step, results.data holds one row instead of the whole file, so memory stays flat. The parser argument gives you pause(), resume(), and abort(); pause and resume are what you need when your per-row work is asynchronous.
Process large files in batcheschunk-mode
Papa.LocalChunkSize = 1024 * 1024 * 5; // 5 MB per chunk
Papa.parse(file, {
header: true,
chunk: (results, parser) => {
parser.pause();
bulkInsert(results.data).then(() => parser.resume());
},
complete: () => console.log("all chunks handled"),
});chunk gives you an array of rows per callback rather than one row, which is much faster than step for bulk inserts. Pause before an async call or Papa keeps feeding you chunks while your writes queue up.
Parse off the main threadweb-worker
Papa.parse(file, {
worker: true,
header: true,
step: (row) => count++,
complete: () => console.log(count, "rows parsed without freezing the UI"),
});Worker mode is browser only and costs a serialization hop per row, so it is slower in total but keeps the page responsive. Functions in your config cannot cross into the worker, which rules out transform and beforeFirstChunk here.
Pipe a Node readable stream through Papanode-stream-pipe
import fs from "node:fs";
import Papa from "papaparse";
const parseStream = Papa.parse(Papa.NODE_STREAM_INPUT, { header: true });
fs.createReadStream("big.csv")
.pipe(parseStream)
.on("data", (row) => console.log(row))
.on("end", () => console.log("finished"));In NODE_STREAM_INPUT mode the step and complete callbacks are unavailable; you listen for 'data' and 'end' events instead. worker, download, and the chunk-size settings do nothing in Node either.
Write CSV from objectsunparse-to-csv
const rows = [
{ name: "Ada", note: 'said "hi", then left' },
{ name: "Grace", note: "multi\nline" },
];
const csv = Papa.unparse(rows, {
columns: ["name", "note"],
delimiter: ",",
newline: "\r\n",
});unparse quotes and escapes for you, which is the whole reason to use it. It builds the entire string in memory, so for very large exports write rows yourself in a loop or stream from the server.
Clean up headers and values while parsingtransform-headers-and-values
Papa.parse(csv, {
header: true,
transformHeader: (h) => h.trim().toLowerCase().replace(/\s+/g, "_"),
transform: (value, field) => (field === "price" ? Number(value) : value.trim()),
});Doing typed conversion in transform is safer than dynamicTyping because you decide field by field. transformHeader also receives the column index as a second argument, useful when duplicate header names need disambiguating.
Check what went wrong and what was detectedinspect-errors-and-meta
const result = Papa.parse(csv, { header: true });
for (const e of result.errors) {
console.log(e.type, e.code, e.message, "row", e.row);
}
console.log(result.meta.delimiter); // ","
console.log(result.meta.linebreak); // "\n"
console.log(result.meta.truncated); // true if preview cut it short
console.log(result.meta.aborted);Papa almost never throws; it collects problems in errors and keeps going, so an empty data array with a populated errors array is the failure mode to check for. TooFewFields and TooManyFields are the two codes you will see most.
Control delimiter detection and peek at the first rowsdelimiter-and-preview
const peek = Papa.parse(csv, {
preview: 5,
delimitersToGuess: [",", ";", "\t", "|"],
});
console.log("detected:", peek.meta.delimiter);
// or skip guessing entirely
Papa.parse(csv, { delimiter: ";", quoteChar: '"', escapeChar: '"' });Auto-detection samples the beginning of the file, so a file whose first rows happen to contain semicolons inside quoted commas can be guessed wrong. Setting delimiter explicitly is both faster and safer when you control the source.
Wrap parsing in a promisepromise-wrapper
function parseCsv(input, config = {}) {
return new Promise((resolve, reject) => {
Papa.parse(input, {
...config,
complete: resolve,
error: reject,
});
});
}
const { data, errors } = await parseCsv(file, { header: true });There is no built-in promise API, so most codebases end up with a helper like this. Do not use it together with step, because step-based parsing resolves with an empty data array.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| csv-parse | npm | You are in Node and want a real stream transform with backpressure, sync and async iterator APIs, and a large set of RFC-edge-case options. |
| fast-csv | npm | Node again, but you want parsing and formatting under one friendly API with per-row validation and transform hooks. |
| d3-dsv | npm | You are parsing modest CSV or TSV in a browser data-visualization context and want a tiny, synchronous parser with a row conversion function. |