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.

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:
// 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:
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: ";" }));node write_demo.mjs--- 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: truevalue became **1**, andfalsebecame an empty field. A consumer that expectstrue/false, or even0, reads the false rows as missing.The
Datebecame **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.2is written0.30000000000000004. That is JavaScript'sStringof a double, as in every language; format money before it gets here.nullandundefinedare both empty. The comma inSmith, Janeand the doubled quotes insaid ""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:
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`);node write_stream.mjs stringify-stream
node write_stream.mjs manual-backpressure
node --max-old-space-size=8192 write_stream.mjs one-stringstringify-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 thecsv-stringifystream into a file, withpipelinehandling backpressure. 4.6 s, 190 MB peak, a 61.0 MB file.**
manual-backpressure**: mycsvCellover each record, written without.write(line), and whenwritereturnsfalse, wait for thedrainevent. 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**:toCsvover an array of all the records, then onewriteFileSync. 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.


