Skip to content
datahacks.dev
CSV

Why Splitting CSV by Comma in JavaScript Goes Wrong

line.split(',') breaks on quoted commas, quotes, line breaks and CRLF. See the failures, why the regex fix is slow, and a 30 line parser that works.

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

The first CSV parser everyone writes in JavaScript is text.split("\n").map(line => line.split(",")). It works on the sample you wrote it against, passes review because it is two lines long, and fails on the first customer whose name contains a comma. This article shows exactly how it fails, why the usual regex patch is not a fix, and what a correct parser looks like. The correct one is short enough to keep in a project that cannot take a dependency, and I measured it against csv-parse to see what it costs. The wider tour of CSV in JavaScript is in the parsing guide.

What split does

String.prototype.split (MDN) cuts a string at every occurrence of a separator. It does not know that some commas are data. Five lines from the people file, split on commas:

split_fail.mjsJavaScript
const lines = [
  "1,Ana Silva,Lisbon,likes tea",
  '2,"Smith, Jane",Boston,"said ""hello"""',
  '3,Bo Chen,Oslo,"line one',
  'line two"',
  "4,Cy Dubois,Lyon,",
];
for (const l of lines) console.log(String(l.split(",").length).padStart(2), JSON.stringify(l.split(",")));
const crlf = "id,qty\r\n1,3\r\n".split("\n").map((l) => l.split(","));
console.log(JSON.stringify(crlf));
Shell
node split_fail.mjs
Text
 4 ["1","Ana Silva","Lisbon","likes tea"]
 5 ["2","\"Smith"," Jane\"","Boston","\"said \"\"hello\"\"\""]
 4 ["3","Bo Chen","Oslo","\"line one"]
 1 ["line two\""]
 4 ["4","Cy Dubois","Lyon",""]
[["id","qty\r"],["1","3\r"],[""]]

Every record in this file has 4 fields. The first line is right. The second, 2,"Smith, Jane",Boston,"said ""hello""", came back as 5 fields: "Smith and Jane" are separate, and the note kept its quotes. The third and fourth lines are one record with a line break inside a quoted note, and split("\n") cut it in two, a 4 field row ["3","Bo Chen","Oslo","\"line one"] and a 1 field row ["line two\""]. The fifth is fine. So a file with nothing unusual in it but three kinds of ordinary data, a comma in a name, a quote in a note and a line break in a note, produced four wrong rows out of five.

The last line shows a smaller trap. The text "id,qty\r\n1,3\r\n" split on "\n" gives ["id","qty\r"], ["1","3\r"] and a final empty row [""]. The carriage return from a Windows line ending is now part of the last field, so qty is "qty\r" and a lookup by name fails, and the trailing newline created an empty record.

The regex patch

Search for the fix and you will find a lookahead regex that splits on a comma only when an even number of quotes follows it:

regex_split.mjsJavaScript
const re = /,(?=(?:[^"]*"[^"]*")*[^"]*$)/;
const cases = [
  "1,Ana,Lisbon",
  '2,"Smith, Jane",Boston',
  '3,"say ""hi, there""",x',
  '4,"a ""quoted"" word",y',
  '5,"unclosed,z',
];
for (const c of cases) console.log(JSON.stringify(c.split(re)));
for (const n of [500, 2000, 8000]) {
  const line = Array.from({ length: n }, (_, i) => `"v${i}"`).join(",");
  const t = performance.now();
  const parts = line.split(re);
  console.log(`${String(n).padStart(5)} quoted fields, ${(line.length / 1000).toFixed(0)}k chars: ${parts.length} parts in ${(performance.now() - t).toFixed(0)} ms`);
}
Shell
node regex_split.mjs
Text
["1","Ana","Lisbon"]
["2","\"Smith, Jane\"","Boston"]
["3","\"say \"\"hi, there\"\"\"","x"]
["4","\"a \"\"quoted\"\" word\"","y"]
["5,\"unclosed","z"]
  500 quoted fields, 3k chars: 500 parts in 1 ms
 2000 quoted fields, 15k chars: 2000 parts in 16 ms
 8000 quoted fields, 63k chars: 8000 parts in 230 ms

On single-line records it does the job. "Smith, Jane" stays whole, and the harder "say ""hi, there""" and "a ""quoted"" word" are correct too, because counting quote parity handles doubled quotes without special cases. The fifth case, an unclosed quote, returned ["5,\"unclosed","z"], which is an odd thing to produce and at least not a crash.

It has two problems. The first is the one you already know: it works on one line at a time, so a record that spans lines is cut before the regex sees it. The second is cost. The lookahead has to scan to the end of the line from every comma it considers, so the work grows with the square of the line length. The timing lines at the bottom of the output show it: a line of 500 quoted fields (3k characters) took 1 ms, 2,000 fields (15k characters) took 16 ms, and 8,000 fields (63k characters) took 230 ms. Sixteen times the fields cost roughly 230 times the time. For a typical 10 column row it is invisible. For a wide export or one very long line, it is a way to freeze a server with a file someone uploaded.

A parser in 30 lines

CSV needs a small amount of state: are we inside a quoted field or not. A loop over characters with that one flag is a complete parser:

parse_csv.mjsJavaScript
// A small RFC 4180 parser: quoted fields, doubled quotes, line breaks inside quotes, CRLF or LF.
export function parseCsv(text) {
  const rows = [];
  let row = [], field = "", inQuotes = false;
  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    if (inQuotes) {
      if (c === '"') {
        if (text[i + 1] === '"') { field += '"'; i++; } else inQuotes = false;
      } else field += c;
    } else if (c === '"' && field === "") {
      inQuotes = true;
    } else if (c === ",") {
      row.push(field); field = "";
    } else if (c === "\n" || c === "\r") {
      if (c === "\r" && text[i + 1] === "\n") i++;
      row.push(field); rows.push(row); row = []; field = "";
    } else field += c;
  }
  if (inQuotes) throw new Error("unterminated quoted field");
  if (field !== "" || row.length) { row.push(field); rows.push(row); }
  return rows;
}

Outside quotes, a comma ends the field, a line break ends the record, and a quote that opens a field starts quoted mode. Inside quotes everything is data, including commas and line breaks, until a quote that is not followed by another quote: "" is an escaped quote and a lone " closes the field. A \r\n pair is one line break. If the text ends while still inside quotes, the function throws instead of returning a half-read file. I compared it with csv-parse on five documents:

Shell
node --max-old-space-size=4096 parse_test.mjs
Text
people             same as csv-parse (5 rows)
crlf               same as csv-parse (3 rows)
nasty              same as csv-parse (3 rows)
noTrailingNewline  same as csv-parse (2 rows)
empty              same as csv-parse (0 rows)
unterminated -> unterminated quoted field
orders-2m: 2000000 rows, total 160174646.36, 2.58s

The documents were the people file with its quoted comma, doubled quotes and embedded line break; the same with CRLF endings; a hand-made file with "" next to commas and a \r\n inside quotes; a file with no trailing newline; and an empty string. All five matched csv-parse, including the row counts, and the unclosed quote threw unterminated quoted field. The last line is the speed: 2,000,000 rows parsed from a string in 2.58 s with a total of 160174646.36, the same as every other tool in this series.

For context, the other tools on the same file. A line-by-line split took 1.15 s, which is the speed you give up to be correct only when the data allows it. csv-parse as a stream took 4.16 s. The hand-written parser sits between them, and it holds the whole file as a string, so its memory is the file's size. For streaming a file that does not fit in memory, use csv-parse or Papa Parse, as in the streams article.

What the 30 lines leave out

The function above is correct for the format and still leaves several decisions to you. It does not strip a byte order mark, so a file saved by Excel yields a first header of id. It does not check that every record has the same number of fields. It does not convert types; every value is a string. It does not detect the delimiter, so a semicolon file needs a parameter. And it does no streaming. Each of those is a place where a library has already made a decision, and each is a reason I use a library when I can. The point of showing the parser is to make it concrete that the problem is state, not cleverness, and to give a fallback for the environment that cannot install anything.

How to tell it worked

Test any CSV code with the awkward values on purpose: a comma, a double quote, a line break, a CRLF, a leading zero, an empty field and a non-ASCII character in a field. Parse, then write the result back with a known good writer and compare with the original. Count the records and compare with a count from another tool, which for a file with embedded newlines will not be the number of lines. And if you ship a regex, give it a line of 10,000 fields and time it.

When split is good enough

When you produced the file yourself with a writer that never quotes, and the data cannot contain a comma, a quote or a line break: ids, enumerated codes, numbers. When the format is not CSV but a delimited log with a documented escape-free grammar. Even then, put a guard in the code: if a line contains a ", stop and use a real parser, because the day the assumption fails you want an exception and not a wrong total. For everything that comes from outside, from a user, a vendor, or an export from a system you do not control, use csv-parse or Papa Parse and spend your time on the part of the problem that is yours.

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.