Skip to content
datahacks.dev
CSV

Writing CSV From an Array of Objects in JavaScript

Turn an array of objects into CSV in JavaScript by hand or with csv-stringify: quoting, dates, booleans, formulas, BOM and a streaming writer.

9 min read
Summarize in AI
Flat illustration of a tree of nested objects beside the document it came from, with the JavaScript logo.

Turning an array of objects into CSV looks like rows.map(r => Object.values(r).join(",")), and that is a bug waiting for the first comma in a name. The correct writer is short, and the library version has defaults that are good for most data and wrong for some. This article writes the same four records three ways, shows what each does to dates, booleans, nulls and formulas, then writes 2,000,000 rows without holding them in memory. The reading side is in the parsing guide, the browser version with a download is in the download article, and the same problem in Python is in the writing article.

A writer by hand

The format is small: a field is quoted when it contains a comma, a double quote or a line break, and an inner double quote is written twice. Records are separated by \r\n. In code:

csv-download.jsJavaScript
// Build a CSV string from an array of objects. Quotes only what needs it.
function csvCell(value) {
  if (value === null || value === undefined) return "";
  let s = value instanceof Date ? value.toISOString() : String(value);
  // text that starts like a formula gets a leading apostrophe; real numbers are left alone
  if (typeof value === "string" && /^[=+\-@\t\r]/.test(s)) s = "'" + s;
  return /[",\r\n]/.test(s) ? '"' + s.replaceAll('"', '""') + '"' : s;
}

function toCsv(rows, columns) {
  const lines = [columns.join(",")];
  for (const row of rows) lines.push(columns.map((c) => csvCell(row[c])).join(","));
  return lines.join("\r\n") + "\r\n";
}

csvCell turns null and undefined into an empty field, a Date into an ISO string, anything else into String(value), and then applies the quoting rule. It also adds a leading apostrophe to a string that begins with =, +, - or @, which I explain below. toCsv takes the rows and the column names in the order you want, builds the header from the same list and joins everything with \r\n. Listing the columns explicitly is the important design choice: Object.keys(rows[0]) gives whatever order the first object happened to have, and a later object with an extra key will not appear at all.

What csv-stringify does by default

The library does the same job with more options. Four records with the kinds of values that cause trouble:

write_demo.mjsJavaScript
import { stringify } from "csv-stringify/sync";
import fs from "node:fs";

const rows = [
  { id: 1, name: "Ana Silva", note: "likes tea", price: 9.5, when: new Date("2025-03-14T10:15:00Z"), active: true },
  { id: 2, name: "Smith, Jane", note: 'said "hello"', price: 0.1 + 0.2, when: null, active: false },
  { id: 3, name: "Zoë Müller", note: "line one\nline two", price: undefined, when: undefined, active: null },
  { id: 4, name: '=SUM(A1:A9)', note: "", price: -5, when: new Date("2025-01-01T00:00:00Z"), active: true },
];
const show = (label, s) => console.log(`--- ${label}\n${JSON.stringify(s)}`);
show("defaults, header from keys", stringify(rows, { header: true }));
show("columns + record_delimiter windows", stringify(rows, { header: true, columns: ["id", "name", "price"], record_delimiter: "windows" }));
show("quoted_string", stringify(rows.slice(0, 2), { header: true, columns: ["id", "name"], quoted_string: true }));
show("bom", stringify(rows.slice(0, 1), { header: true, columns: ["id"], bom: true }).slice(0, 8));
show("escape_formulas", stringify(rows.slice(3), { columns: ["id", "name", "price"], escape_formulas: true }));
show("cast date + boolean", stringify(rows.slice(0, 2), { columns: ["when", "active"], cast: { date: (d) => d.toISOString().slice(0, 10), boolean: (b) => (b ? "yes" : "no") } }));
try { stringify([{ a: { nested: 1 } }], { header: true }); console.log("--- nested object\n", JSON.stringify(stringify([{ a: { nested: 1 } }], { header: true }))); } catch (e) { console.log("--- nested object\n", e.code ?? e.message.slice(0, 80)); }
try { console.log("--- bigint\n", JSON.stringify(stringify([{ n: 12345678901234567890n }]))); } catch (e) { console.log("--- bigint\n", e.code ?? e.message.slice(0, 80)); }
show("delimiter ;", stringify([["a", "b;c", "d"]], { delimiter: ";" }));
Shell
node write_demo.mjs
Text
--- defaults, header from keys
"id,name,note,price,when,active\n1,Ana Silva,likes tea,9.5,1741947300000,1\n2,\"Smith, Jane\",\"said \"\"hello\"\"\",0.30000000000000004,,\n3,Zoë Müller,\"line one\nline two\",,,\n4,=SUM(A1:A9),,-5,1735689600000,1\n"
--- columns + record_delimiter windows
"id,name,price\r\n1,Ana Silva,9.5\r\n2,\"Smith, Jane\",0.30000000000000004\r\n3,Zoë Müller,\r\n4,=SUM(A1:A9),-5\r\n"
--- quoted_string
"\"id\",\"name\"\n1,\"Ana Silva\"\n2,\"Smith, Jane\"\n"
--- bom
"id\n1\n"
--- escape_formulas
"4,'=SUM(A1:A9),-5\n"
--- cast date + boolean
"2025-03-14,yes\n,no\n"
--- nested object
 "a\n\"{\"\"nested\"\":1}\"\n"
--- bigint
 "12345678901234567890\n"
--- delimiter ;
"a;\"b;c\";d\n"

The first block is stringify(rows, { header: true }) with no other options. The line for the first record is 1,Ana Silva,likes tea,9.5,1741947300000,1. Read it from the right:

  • The active: true value became **1**, and false became an empty field. A consumer that expects true/false, or even 0, reads the false rows as missing.

  • The Date became **1741947300000**, milliseconds since 1970. It is a faithful number and nothing that opens the file as a spreadsheet will recognize it as a date.

  • The price 0.1 + 0.2 is written 0.30000000000000004. That is JavaScript's String of a double, as in every language; format money before it gets here.

  • null and undefined are both empty. The comma in Smith, Jane and the doubled quotes in said ""hello"" are correct, and the line break inside "line one\nline two" is preserved inside quotes.

  • The default record delimiter is \n, not CRLF.

None of that is a bug in the library, and every one of them is a decision that your data should make. The fix for dates and booleans is cast:

The cast date + boolean block shows 2025-03-14,yes and ,no: a cast function for each type, so a date prints as an ISO date and a boolean as the words the consumer expects (the second row has a null date, still empty). Write what the reader needs, not what the runtime prints.

Other options from the same output: record_delimiter: "windows" gave \r\n line endings; quoted_string: true quoted every string field ("Ana Silva") and left numbers bare; bom: true prepended the character  (three bytes in UTF-8), which Excel on Windows is widely reported to need for UTF-8, a claim I could not test here; delimiter: ";" wrote a;"b;c";d, quoting the field that contains the new delimiter.

Formulas

A spreadsheet treats a cell that starts with =, +, - or @ as a formula. If the value came from a user, =HYPERLINK(...) in a name column becomes a clickable link or a data-exfiltration attempt when someone opens the file. The library has an option:

escape_formulas: true wrote the record 4,=SUM(A1:A9),-5 as 4,'=SUM(A1:A9),-5: a leading apostrophe on the text. Look at the third value: -5 is a number, not text, and was left alone, which matters. My hand-written writer in the first section applies the guard only to strings (typeof value === "string") for the same reason; an earlier version prefixed every value that began with -, and turned the number minus five into the string '-5. The apostrophe is a mitigation that changes the data, so apply it only to files that people will open in a spreadsheet, and not to files that a program will read back.

Nested values and big numbers

An object inside a record is serialized as JSON in a quoted field:

The nested object block shows "{""nested"":1}": the object {nested: 1} as JSON, quoted, with the inner quotes doubled. That is reasonable and almost never what a spreadsheet user wants, so flatten the objects you care about before writing. A BigInt was written in full, 12345678901234567890, with no loss. Numbers beyond 2^53 as plain JavaScript numbers would already have lost digits before they reached the writer.

Writing 2,000,000 rows

The same writers on 2,000,000 generated records, with a name, an amount and a note that is quoted one row in fifty:

write_stream.mjsJavaScript
import fs from "node:fs";
import { once } from "node:events";
import { stringify } from "csv-stringify";
import { pipeline } from "node:stream/promises";
import { Readable } from "node:stream";
import { createRequire } from "node:module";
const N = 2_000_000;
const mode = process.argv[2];
function* records() {
  for (let i = 1; i <= N; i++) yield { id: i, name: `Customer ${String(i).padStart(7, "0")}`, amount: (i % 1000) / 10, note: i % 50 === 0 ? 'said "hi", left' : "" };
}
const cols = ["id", "name", "amount", "note"];
const t = performance.now();
if (mode === "stringify-stream") {
  await pipeline(Readable.from(records()), stringify({ header: true, columns: cols }), fs.createWriteStream("/tmp/w-stream.csv"));
} else if (mode === "manual-backpressure") {
  const { default: _ } = { default: 0 };
  const code = fs.readFileSync(new URL("./tocsv_core.js", import.meta.url), "utf8") + "\nreturn { csvCell };";
  const { csvCell } = new Function(code)();
  const out = fs.createWriteStream("/tmp/w-manual.csv");
  out.write(cols.join(",") + "\r\n");
  let waits = 0;
  for (const r of records()) {
    if (!out.write(cols.map((c) => csvCell(r[c])).join(",") + "\r\n")) { waits++; await once(out, "drain"); }
  }
  out.end(); await once(out, "finish");
  console.log("   drain waits:", waits);
} else if (mode === "one-string") {
  const code = fs.readFileSync(new URL("./tocsv_core.js", import.meta.url), "utf8") + "\nreturn { toCsv };";
  const { toCsv } = new Function(code)();
  fs.writeFileSync("/tmp/w-string.csv", toCsv([...records()], cols));
}
const file = { "stringify-stream": "/tmp/w-stream.csv", "manual-backpressure": "/tmp/w-manual.csv", "one-string": "/tmp/w-string.csv" }[mode];
console.log(`${mode.padEnd(20)} ${N} rows, ${(fs.statSync(file).size / 1e6).toFixed(1)} MB in ${((performance.now() - t) / 1000).toFixed(1)}s, peak rss ${(process.resourceUsage().maxRSS / 1024).toFixed(0)} MB`);
Shell
node write_stream.mjs stringify-stream
node write_stream.mjs manual-backpressure
node --max-old-space-size=8192 write_stream.mjs one-string
Text
stringify-stream     2000000 rows, 61.0 MB in 4.6s, peak rss 190 MB
   drain waits: 961
manual-backpressure  2000000 rows, 63.0 MB in 2.1s, peak rss 175 MB
one-string           2000000 rows, 63.0 MB in 2.0s, peak rss 930 MB
  • **stringify-stream**: Readable.from(records()) piped through the csv-stringify stream into a file, with pipeline handling backpressure. 4.6 s, 190 MB peak, a 61.0 MB file.

  • **manual-backpressure**: my csvCell over each record, written with out.write(line), and when write returns false, wait for the drain event. 2.1 s, 175 MB, 63.0 MB. It waited 961 times, which is the stream telling the loop to slow down 961 times.

  • **one-string**: toCsv over an array of all the records, then one writeFileSync. 2.0 s and 930 MB of peak memory for the same 63.0 MB of output.

The faster ones are the hand-written loops, and the safe one is the stream: its memory does not depend on the number of rows. The one-string version is as fast as the best stream and about five times the memory, because it holds every record and the whole text at once. The difference between the 61.0 and 63.0 MB files is line endings: CRLF adds one byte for each of the 2,000,001 lines. The sentence to remember from the manual loop is the drain wait. A write() call that you ignore returns false to say "slow down", and a loop that ignores it queues the whole file in memory, which is exactly what the string version did.

How to tell it worked

Read the file back with a different parser: Papa Parse, DuckDB, or Python's csv module with strict=True. Compare the record count with the array length. Look at one record that has a comma, a quote, a line break, a null, a date and a boolean. Check the first bytes for a BOM and the line endings with xxd. And for a file that a spreadsheet will open, put a value that starts with = in the test data and see what the cell shows.

Defaults that change your data

A library that picks a representation for dates and booleans is making a choice for you, and it will be right until the consumer disagrees. The three to check on every writer are dates (the format and the zone), booleans (the words) and nulls (empty or a marker), and the fourth is numbers (the precision). Write down the contract with the reader, even if the reader is another script of yours, and test it with a file that has all four. And when a CSV is the only interface between two systems, keep the writer and its settings in one module with a test, so that the next person who changes a column does not change a representation by accident.

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.