Skip to content
datahacks.dev
CSV

Parsing CSV in JavaScript and Node Without Surprises

A tested guide to CSV in JavaScript: csv-parse, Papa Parse and a hand-written parser timed on 2,000,000 rows, plus streams, types, uploads and downloads.

20 min read
Summarize in AI
Flat illustration of a spreadsheet grid with a selected cell and a formula bar, with the JavaScript logo.

JavaScript has two jobs with CSV, and they are different jobs. On a server, Node reads a file or an upload that may be hundreds of megabytes, validates it, and puts it somewhere. In a browser, the same format arrives from a file input or goes out as a download, and what matters is that the page does not freeze. The libraries are good. The mistakes are mostly the same few: splitting on commas, reading the whole file into memory, letting a parser turn identifiers into numbers, forgetting that Excel wants a BOM. This guide is the one I would hand a colleague before they write the import. It covers how to choose a parser with measurements, the habits that prevent most bugs, reading and writing in both environments, converting and loading, and a table that sends each symptom to its fix.

Every number was measured and every output is the output I got. The deeper articles are linked where they apply.

Choosing a parser

Four ways to parse a CSV in Node cover most needs: the library you stream through, the library you give a string, a line-by-line split, and a parser you write. I summed the amount column of the 2,000,000 row file with each, in its own process, and recorded time and peak resident memory:

Shell
node --version; npm ls --depth=0 2>/dev/null | tail -n 3
Text
v24.22.1
+-- [email protected]
`-- [email protected]
Shell
for m in split csv-parse-stream csv-parse-columns csv-parse-sync papaparse-stream papaparse-string; do node --max-old-space-size=4096 bench.mjs $m; done
Text
split              rows=2000000 total=160174646.36 time=1.15s peak_rss=70MB
csv-parse-stream   rows=2000000 total=160174646.36 time=4.16s peak_rss=74MB
csv-parse-columns  rows=2000000 total=160174646.36 time=6.26s peak_rss=78MB
csv-parse-sync     rows=2000000 total=160174646.36 time=4.31s peak_rss=1132MB
papaparse-stream   rows=2000000 total=160174646.36 time=1.12s peak_rss=84MB
papaparse-string   rows=2000000 total=160174646.36 time=2.31s peak_rss=1179MB
ParserTimePeak memoryCorrect on quotes
readline + split(",")1.15 s70 MBNo
csv-parse, stream, arrays4.16 s74 MBYes
csv-parse, stream, columns: true6.26 s78 MBYes
csv-parse/sync4.31 s1,132 MBYes
Papa Parse, stream with step1.12 s84 MBYes
Papa Parse, whole string2.31 s1,179 MBYes
30 line hand-written parser, whole string2.58 snot measuredYes, tested

All of them found 160174646.36. The timings need three readings.

The split row is the number to beat, and it is only valid because the benchmark file has no quotes; on real data it is wrong, as the splitting article shows with five lines from one file coming back as 4, 5, 4, 1 and 4 fields. Papa Parse as a stream matched it for speed and was correct. csv-parse is strict and detailed, and was about four times slower on this file, with columns: true costing another 50%. The memory column is the one that decides in practice: both sync and whole-string forms used over a gigabyte for a 130 MB file, and the streaming forms used 70 to 84 MB. A file that is bigger next year makes the sync version fail; the stream version does not care.

My choice follows from that. For a server that parses uploads from the outside, I use csv-parse as a stream, because the error codes, line numbers and strict defaults are worth the speed on data I do not trust. For throughput on clean data, or in the browser, Papa Parse. For a project that cannot take a dependency, or a spreadsheet-style tool that must run in a worker with no packages, the 30 line parser is correct on the test cases and fast enough. For a one-off look at a file, none of them: DuckDB reads the CSV without any code, in under a second, as in the DuckDB guide.

Five habits that prevent most bugs

**1. Do not split a CSV from outside.** The failure is silent. "Smith, Jane" becomes two fields and everything to its right shifts; a note with a line break becomes two rows; a CRLF leaves \r on the last field. The regex that people reach for to fix it handles quoted commas, and is quadratic in the line length: 1 ms for a line of 500 fields, 16 ms for 2,000 and 230 ms for 8,000. Use a parser.

2. Stream anything that can grow. A stream holds a few thousand records at a time. A sync parse holds the file twice. Use pipeline from node:stream/promises, which forwards errors and cleans up; leave an async loop with break and the file closes at once (17 ms to stop after 3 of 2,000,000 records). See reading a CSV in Node with streams.

3. Keep identifiers as text. cast: true in csv-parse converted ZIP 02134 to 2134 and 00501 to 501. Papa Parse's dynamicTyping: true did the same, and the object form dynamicTyping: { id: true } left the ZIPs alone. The same trap is in pandas. Name the columns that are numbers, and treat the rest as strings. A cast function that checks the column is a schema in six lines.

4. Handle the BOM and the encoding on purpose. A file saved by Excel can start with ef bb bf, which csv-parse turns into a header of "id" unless bom: true; Papa Parse strips it without being asked. A file in Windows-1252 read as UTF-8 returns Jos�, a permanent loss, while a TextDecoderStream("windows-1252") before the parser, or encoding: "windows-1252" in Papa Parse, returns José. Decide the encoding before the parser sees the bytes; the detection method is in the encoding article.

5. Count, and never swallow errors. The number of records in should match an independent count, which for a file with line breaks inside fields is not the number of lines. csv-parse throws with a code, such as CSV_RECORD_INCONSISTENT_COLUMNS with got 5 on line 3; Papa Parse does not throw, it puts entries like FieldMismatch/TooManyFields in results.errors, and if you only read results.data you will import the bad rows. Tolerant options (relax_column_count, skip_records_with_error) are a policy and not a repair: count what they dropped.

Reading in Node

The reading pattern is a file stream into the parser, consumed by an async loop or a pipeline:

JavaScript
import fs from "node:fs";
import { parse } from "csv-parse";

for await (const record of fs.createReadStream(path).pipe(parse({ columns: true, bom: true }))) {
  // one record at a time
}

On the 2,000,000 row file this peaked at 78 MB. The articles on streams and csv-parse's options go through the details: a pipeline with a Transform that filtered 25 refunded orders over 500 at 63 MB; on_record as the cheap place to filter, keeping 400,135 records in 6.14 s against 6.02 s for no filter at all; max_record_size as the guard against an unclosed quote swallowing the file. Two things from those articles are worth repeating. The unclosed-quote error reports the line where the file ended (line 5000 in a test file whose stray quote was on line 3), so the line number is "somewhere before here". And benchmark each configuration in its own process: my first run, with all seven options in one process, showed 15 to 16 seconds for variants that take 5 to 7 in a fresh process, because the heap from earlier passes slowed the later ones.

Reading in the browser

In a page the parser competes with painting and input. Papa Parse reads a File straight from an <input>, and the question is how much of the page you freeze. On 500,000 rows (31.7 MB):

  • main thread, one call: 587 ms total, and the page unresponsive for 590 ms

  • main thread with chunk: 458 ms total, longest stall 23 ms

  • worker returning one result: 1,009 ms total, longest stall 215 ms

  • worker with step: 672 ms, stall 71 ms

  • worker with chunk: 561 ms, stall 11 ms

The surprise is the third row: moving the parse to a worker moved the freeze to the moment the results came back, because deserializing 500,000 objects is main-thread work. Stream results in chunks. The Papa Parse article has the setup, the measurement script, and a failure: a worker with a relative URL did not finish in 8 seconds, and with an absolute URL it parsed 100,000 rows in 237 ms. It also covers skipEmptyLines (a file ending in a newline produced a fifth, empty row), the error entries, and the encoding option for a Windows-1252 upload.

Types, once more

Types deserve a second look, because three libraries make the same mistake in different words. csv-parse with cast: true, Papa Parse with dynamicTyping: true and csv-stringify going the other way all make decisions about values. In reading, numbers lose their leading zeros. In writing, csv-stringify wrote new Date("2025-03-14T10:15:00Z") as 1741947300000, true as 1, false as an empty field and 0.1 + 0.2 as 0.30000000000000004. Its cast option fixed the dates and booleans (2025-03-14, yes/no), and escape_formulas: true prefixed an apostrophe to text beginning with = and left the number -5 alone. The full table of defaults is in the writing article. The rule for both directions is the same: say what the contract is, in code, for every column that is not plain text.

Writing and downloading

A hand-written writer is five lines: quote a field that contains a comma, a double quote or a line break, double the inner quotes, join records with \r\n. The streaming form respects backpressure. Writing 2,000,000 rows took 2.1 s at 175 MB with a loop that waits for drain (961 waits), 4.6 s at 190 MB with the csv-stringify stream and 2.0 s at 930 MB building one string, so the memory is the difference. In a page, the pattern is Blob, object URL, anchor; a million rows built in 363 ms and wrapped in a Blob in 51 ms. A Blob made from a JavaScript string is always UTF-8, and ["", csv] adds the three BOM bytes ef bb bf that Excel on Windows is widely reported to need, a claim I did not test. The download article shows the helper, and the garbled characters article shows what Express sends: text/csv; charset=utf-8 for strings and buffers, plain text/csv from bare http, and why a correct charset header does not stop Excel from showing Zoë.

Converting and loading

Converting a CSV to JSON is three lines for a small file and a Transform for a large one. Streaming 2,000,000 rows to NDJSON took 8.8 s at 118 MB; building one array took 12.7 s at 1,614 MB; DuckDB wrote the same data in 1.7 s with real types (article). Loading into a database is where the choice of API matters most, and the speed depends on the database call, not the file read. Into PostgreSQL 18.6 from Node: a COPY fed straight from the file stream took 2.32 s (861,282 rows per second), 1,000-row multi-row inserts took 21.01 s, COPY through a JavaScript parse and re-serialize took 33.43 s, and one insert per row ran at 3,958 rows per second. With a bad row, the single COPY failed with 22P04 extra data after last expected column at line 50 and left 0 rows. In SQLite via node:sqlite, autocommit on disk managed 490 rows per second, and a single transaction 277,915 rows per second. See streaming a huge CSV into a database.

Numbers, dates and the strings between

A CSV parser gives you strings. Turning them into numbers and dates is JavaScript's job, and the language has opinions. A short script, run under three time zones with the TZ variable:

dates_numbers.mjsJavaScript
const s = "2025-03-14 10:15:00";
const iso = "2025-03-14T10:15:00";
console.log(`TZ=${process.env.TZ}`.padEnd(24),
  "space form ->", new Date(s).toISOString(),
  "| T form ->", new Date(iso).toISOString(),
  "| Z form ->", new Date(iso + "Z").toISOString());
const dstGap = "2025-03-09T02:30:00"; // does not exist in New York
try { console.log(`  ${dstGap} in this zone ->`, new Date(dstGap).toISOString()); } catch (e) { console.log("  error", e.message); }
const dateOnly = new Date("2025-03-14");
console.log("  date-only string ->", dateOnly.toISOString(), "| local getDate():", dateOnly.getDate());
if (process.env.TZ === "UTC") {
  const big = "12345678901234567890";
  console.log("Number(id)        :", String(Number(big)), "| BigInt(id):", String(BigInt(big)));
  console.log("sum of 0.1 + 0.2  :", Number("0.1") + Number("0.2"));
  console.log("cents as integers :", Math.round(Number("19.99") * 100) + Math.round(Number("0.01") * 100), "(cents)");
  console.log("Number('')        :", Number(""), "| Number(' ')  :", Number(" "), "| Number('1,5'):", Number("1,5"), "| parseFloat('1,5'):", parseFloat("1,5"));
  console.log("Number('0x1F')    :", Number("0x1F"), "| Number('1e3'):", Number("1e3"), "| Number('Infinity'):", Number("Infinity"));
}
Shell
for tz in UTC America/New_York Asia/Tokyo; do TZ=$tz node dates_numbers.mjs; done
Text
TZ=UTC                   space form -> 2025-03-14T10:15:00.000Z | T form -> 2025-03-14T10:15:00.000Z | Z form -> 2025-03-14T10:15:00.000Z
  2025-03-09T02:30:00 in this zone -> 2025-03-09T02:30:00.000Z
  date-only string -> 2025-03-14T00:00:00.000Z | local getDate(): 14
Number(id)        : 12345678901234567000 | BigInt(id): 12345678901234567890
sum of 0.1 + 0.2  : 0.30000000000000004
cents as integers : 2000 (cents)
Number('')        : 0 | Number(' ')  : 0 | Number('1,5'): NaN | parseFloat('1,5'): 1
Number('0x1F')    : 31 | Number('1e3'): 1000 | Number('Infinity'): Infinity
TZ=America/New_York      space form -> 2025-03-14T14:15:00.000Z | T form -> 2025-03-14T14:15:00.000Z | Z form -> 2025-03-14T10:15:00.000Z
  2025-03-09T02:30:00 in this zone -> 2025-03-09T07:30:00.000Z
  date-only string -> 2025-03-14T00:00:00.000Z | local getDate(): 13
TZ=Asia/Tokyo            space form -> 2025-03-14T01:15:00.000Z | T form -> 2025-03-14T01:15:00.000Z | Z form -> 2025-03-14T10:15:00.000Z
  2025-03-09T02:30:00 in this zone -> 2025-03-08T17:30:00.000Z
  date-only string -> 2025-03-14T00:00:00.000Z | local getDate(): 14

Dates. The string 2025-03-14 10:15:00 has no zone, and new Date() reads it in the machine's local zone. With TZ=UTC the instant is 10:15:00Z; with TZ=America/New_York the same string became 14:15:00Z, and with TZ=Asia/Tokyo it became 01:15:00Z. The parse is the same code, the instants are hours apart, and a server in a different region from the one that wrote the CSV will quietly shift every timestamp. The only reliable fix is to know which zone the file is in, and to say it: parse 2025-03-14T10:15:00 plus an explicit offset or Z, or use a date library with a named zone. Appending Z produced 10:15:00Z in every zone, which is right only if the file is in UTC.

The daylight saving gap is worse. 2025-03-09T02:30:00 does not exist in New York, because the clocks jumped from 02:00 to 03:00. new Date did not complain; it returned 07:30:00Z, which is 03:30 local time, a different wall-clock time with no error. And a date-only string, 2025-03-14, is parsed as UTC midnight, so in New York getDate() returned 13: the date was the day before when read in local time. Any code that reads a date column with local-time getters is wrong for half the world.

Numbers. Number("12345678901234567890") printed 12345678901234567000: a JavaScript number is a double, and an identifier of that size loses its last digits; BigInt("12345678901234567890") kept them all. So a column of long ids should stay a string, or become a BigInt if you must compute. Number("0.1") + Number("0.2") gave 0.30000000000000004, as everywhere, so money wants integer cents: the two prices added in cents gave 2000. And the conversion functions have quirks that a CSV will find: Number("") and Number(" ") are 0, so an empty cell becomes zero, not missing; Number("1,5") is NaN while parseFloat("1,5") is 1, silently dropping the ,5; Number("0x1F") is 31 and Number("Infinity") is Infinity. Check for empty first, accept only the pattern you expect with a regular expression, and convert after.

Input that is not a local file

Most CSV arrives from somewhere else: a URL, a compressed archive, a form upload. The parser does not care, because it reads a stream, and each source is a stream. I tested three on the 2,000,000 row file with a small Express server and a Node client:

upload_server.mjsJavaScript
import fs from "node:fs";
import express from "express";
import { parse } from "csv-parse";
import { pipeline } from "node:stream/promises";
import { Writable } from "node:stream";

const app = express();
app.get("/orders.csv", (req, res) => {
  res.type("text/csv");
  fs.createReadStream("/batches/csv-0001-0200/work/orders-2m.csv").pipe(res);
});

// Parse the request body as it arrives; never buffer the upload.
app.post("/upload", async (req, res) => {
  let rows = 0, total = 0;
  try {
    await pipeline(
      req,
      parse({ columns: true, bom: true, max_record_size: 1024 * 1024 }),
      new Writable({ objectMode: true, write(rec, _e, cb) { rows++; total += Number(rec.amount); cb(); } }),
    );
    res.json({ rows, total: Number(total.toFixed(2)) });
  } catch (e) {
    res.status(400).json({ error: e.code ?? "PARSE_ERROR", message: e.message });
  }
});
app.listen(3113, () => console.log("upload server on 3113"));
upload_client.mjsJavaScript
import fs from "node:fs";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { createGunzip } from "node:zlib";
import { parse } from "csv-parse";
import { Writable } from "node:stream";

const base = "http://127.0.0.1:3113";
// 1. parse a remote CSV as it downloads
let t = performance.now(), n = 0;
const res = await fetch(base + "/orders.csv");
await pipeline(Readable.fromWeb(res.body), parse({ from_line: 2 }), new Writable({ objectMode: true, write(_r, _e, cb) { n++; cb(); } }));
console.log(`remote stream : ${n} records in ${((performance.now() - t) / 1000).toFixed(2)}s, rss ${(process.resourceUsage().maxRSS / 1024).toFixed(0)} MB`);
// 2. a gzip file, decompressed on the way in
t = performance.now(); n = 0;
await pipeline(fs.createReadStream("/batches/csv-0001-0200/work/orders-2m.csv.gz"), createGunzip(), parse({ from_line: 2 }), new Writable({ objectMode: true, write(_r, _e, cb) { n++; cb(); } }));
console.log(`gzip file     : ${n} records in ${((performance.now() - t) / 1000).toFixed(2)}s`);
// 3. uploads
const post = async (label, body) => {
  const r = await fetch(base + "/upload", { method: "POST", headers: { "content-type": "text/csv" }, body, duplex: "half" });
  console.log(`${label.padEnd(14)}: HTTP ${r.status} ${JSON.stringify(await r.json())}`);
};
await post("good file", fs.readFileSync("/batches/csv-0001-0200/work/web/orders-100k.csv"));
await post("quote not closed", fs.readFileSync("/batches/csv-0001-0200/lab/data/py/unterminated.csv"));
await post("ragged rows", fs.readFileSync("/batches/csv-0001-0200/lab/data/py/ragged2.csv"));
await post("bom file", fs.readFileSync("/batches/csv-0001-0200/lab/data/py/bom.csv"));
Shell
node upload_client.mjs
Text
remote stream : 2000000 records in 4.02s, rss 102 MB
gzip file     : 2000000 records in 5.25s
good file     : HTTP 200 {"rows":100000,"total":8010801.54}
quote not closed: HTTP 400 {"error":"CSV_QUOTE_NOT_CLOSED","message":"Quote Not Closed: the parsing is finished with an opening quote at line 5000"}
ragged rows   : HTTP 400 {"error":"CSV_RECORD_INCONSISTENT_COLUMNS","message":"Invalid Record Length: columns length is 4, got 5 on line 3"}
bom file      : HTTP 200 {"rows":2,"total":null}

A remote CSV parsed while it downloads: fetch, then Readable.fromWeb(res.body) into the parser. 2,000,000 records in 4.02 s at 102 MB peak, no temp file, and the first record is available before the last byte arrives. A gzip file goes through zlib.createGunzip() first: 2,000,000 records in 5.25 s from the 42 MB archive, with no unpacked copy on disk.

An upload is the interesting one. The handler pipes the request itself into the parser, so the file is never buffered in memory or written to disk before it is checked, and the response reports what happened. The good 100,000 row file returned HTTP 200 with {"rows":100000,"total":8010801.54}. The file with a stray quote returned HTTP 400 with CSV_QUOTE_NOT_CLOSED and the "opening quote at line 5000" message, and the ragged file returned 400 with CSV_RECORD_INCONSISTENT_COLUMNS and got 5 on line 3. A client can show those. The last upload was a BOM file with two records: it parsed (bom: true) as two rows, and its total came out null because that file has no amount column, a reminder that parsing a file and having the columns you expect are two different checks. Validate the header against the columns you need before you process a single row.

A production upload endpoint needs more than this. A size limit on the request, so that a client cannot send 20 GB. A limit on decompressed size when the upload may be gzipped, since a small archive can expand to a huge stream (I did not test a decompression bomb, and the guard is a counter in a Transform that aborts). A max_record_size, set to 1 MB in the server above, so that one unclosed quote does not make the whole file a single record. A cap on the number of rows. A timeout. And a plan for the rejects: parse errors come back as 400s, but a file with one bad row in a million deserves a report of the bad rows and the choice to accept the rest, not a flat refusal. Whatever you decide, do the work with a stream, in constant memory, and count what you accepted and rejected.

A short list of security habits

CSV code sits on the edge of the system, and a few failures are worth naming. A formula in a cell is a way to attack the person who opens an export, so guard strings that begin with =, +, - or @ on files meant for spreadsheets, and only strings, since the guard turned a legitimate -5 into text when I applied it to numbers. A file name built from data is a path: the awk splitter in the shell guide wrote a file outside its folder for a value of ../escaped, and a Node script that does fs.createWriteStream(row.country + ".csv") has the same bug. A regular expression applied to a user's line can be a denial of service: the lookahead split took 230 ms on a line of 8,000 fields and grows quadratically. An upload with no size limit is a way to fill a disk. Each of those has a small, testable guard, and each is cheaper to add before the first incident.

A parser of your own, and a test for any of them

The 30 line parser is a loop with one flag, inQuotes. Outside quotes a comma ends the field, a line break ends the record, and a quote at the start of a field turns on quote mode; inside quotes everything is data until a quote that is not followed by another quote. It agreed with csv-parse on five documents (quoted commas, doubled quotes, CRLF, a record break inside quotes, no trailing newline, an empty string) and threw on an unclosed quote. It leaves out the BOM, the column-count check, streaming and type conversion, which is why it is a fallback and not my default. The code is in the splitting article.

The test I would copy into any project writes awkward rows with one library and reads them with another. Eleven rows, covering a comma, a double quote, a line break, a CRLF inside a field, a leading zero, non-ASCII text with an emoji, an empty string, padded spaces, and quotes at the start and end of a value, through three writers and three parsers:

Shell
node roundtrip.mjs
Text
writer \ parser   csv-parse           Papa.parse          parseCsv (30 lines) 
csv-stringify     ok                  ok                  ok                  
Papa.unparse      ok                  ok                  ok                  
manual toCsv      ok                  ok                  ok                  

All nine combinations came back equal. That is what should be expected of libraries that implement the format, and it is the property that fails first when someone adds a hand-written writer with a bug. Keep it as a unit test, add the rows your own data has taught you to fear, and run the other side of your pipeline (the partner's importer, the database load) on the same file.

A checklist for an import job

When I review a CSV import written in JavaScript, these are the questions, in the order I ask them. Does it use a real parser, and is the choice written down? Does it stream, and does the memory stay flat when the file is ten times larger? Is the encoding decided, with a BOM option, and tested with an accent and a Windows-1252 byte? Are the identifier columns strings, by name? Are blank cells handled on purpose, since Number("") is 0? Are dates parsed in a stated zone? Does the header get validated against the columns the code needs, before the first row is processed? Are structural errors reported with a code and a line, and is the count of accepted and rejected rows part of the result? Is there a size limit, a record size limit and a row limit? Is the destination loaded with the database's bulk path, in a transaction, and does a rerun leave the same result? And is there a test that writes awkward rows with one library and reads them with another? A job that answers yes to all of those is usually boring to operate, which is the goal.

Symptom, cause, fix

You seeUsually meansRead
Wrong city, shifted columns, extra rowssplit(",") on quoted datasplitting
"id" as the first keyUTF-8 BOM, bom: falsestreams
Jos� after readingWindows-1252 read as UTF-8encoding
Zoë in ExcelUTF-8 without BOM opened as a legacy code pagegarbled downloads
ZIP 02134 is 2134cast: true or dynamicTyping: truecsv-parse options
Fifth, empty row after the dataNo skipEmptyLines in Papa ParsePapa Parse
CSV_QUOTE_NOT_CLOSED at the last lineA stray quote far earlierstreams
Out of memory on a big fileSync parse or one big arraystreams, JSON
Page freezes during importMain-thread parsePapa Parse
Date column is 1741947300000csv-stringify default for Datewriting
Import takes an hourRow-by-row insertsdatabase loading

Versions and what to pin

The numbers in this guide are specific to the versions in the callout, and CSV libraries do change defaults between major versions: options get renamed, errors get new codes, a strict check becomes the default. Pin the versions of csv-parse, csv-stringify and Papa Parse in the lockfile, read the changelog before a major upgrade, and keep the round-trip test and a small fixture file with the awkward rows in the repository, so an upgrade that changes a representation fails a test instead of a customer's import. Print the parser version in the import job's log. When a file that worked last month fails this month, the first question is whether the file changed or the code did, and a log line answers it.

How to tell it worked

Compare the record count with an independent tool: qsv count, DuckDB, or the count from a second library. Compare one numeric total. Look at the types of the first record, and at a record that has a comma, a quote, a line break and an accent. Run the round-trip test. Check the first bytes of anything you write with xxd for a BOM and the line endings. And watch memory: process.resourceUsage().maxRSS for a Node job, the performance panel for a page.

When JavaScript is the wrong place for this job

If the work is filtering, grouping and joining, a SQL engine is shorter and much faster than a loop: the same sum took 0.2 s in DuckDB on this file. If the file is clean and the destination is a database, let the database load it, and keep JavaScript for the orchestration. If the data is for a spreadsheet user, most of the work is in the encoding, the BOM and the line endings, and the code is the small part. If the problem is a producer that writes invalid CSV, no parser option is a fix; the cheapest repair is a conversation with whoever writes the file. For the same task in other environments, see the Python guide and CSV in the shell; the format they all follow is RFC 4180, and the libraries here are documented at csv.js.org and papaparse.com.

Also inScripting
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.