Convert JSON to CSV in JavaScript, With or Without a Library
· by webmaster
You have an array of objects and someone wants a spreadsheet. CSV looks trivial, but a hand-rolled converter usually breaks the first time a value contains a comma. This guide gives you a small function that escapes correctly, a flattener for nested objects, a Node.js script that writes a file, a browser download button, and a short look at two libraries. If you only need a one-off file, paste the data into the JSON to CSV converter and skip the code.
CSV is harder than it looks because of quoting
No single CSV grammar exists that every tool agrees on. However, RFC 4180 describes what spreadsheets expect, and three of its rules do most of the work:
- Fields containing a comma, a double quote, or a line break must be wrapped in double quotes.
- A double quote inside a quoted field is written as two double quotes.
- Records are separated by CRLF (
\r\n), although most parsers also accept\n.
Get the quoting rule right before anything else. Rule 1 is the one hand-written converters skip. Without it, “Smith, John” becomes two columns, every cell to its right shifts by one, and the error may go unnoticed for weeks. Once escaping is correct, the rest is bookkeeping.
Flat arrays convert with plain JavaScript
Start with the easy case, where every object has the same keys. Take the headers from the first object, escape every value, and join.
function escapeCsvField(value) {
if (value === null || value === undefined) return "";
const str = String(value);
if (/[",\r\n]/.test(str)) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
}
function jsonToCsv(rows) {
if (rows.length === 0) return "";
const headers = Object.keys(rows[0]);
const lines = [headers.map(escapeCsvField).join(",")];
for (const row of rows) {
lines.push(headers.map((h) => escapeCsvField(row[h])).join(","));
}
return lines.join("\r\n");
}
const users = [
{ id: 1, name: "Ada Lovelace", note: 'Said "hello", then left' },
{ id: 2, name: "Grace Hopper", note: "Line one\nLine two" },
{ id: 3, name: "Linus", note: null },
];
console.log(jsonToCsv(users));
Output:
id,name,note
1,Ada Lovelace,"Said ""hello"", then left"
2,Grace Hopper,"Line one
Line two"
3,Linus,
The second row spans two physical lines. That is correct: Excel, Google Sheets and Python’s csv module all read it as one record, because the line break sits inside quotes. null and undefined come out as empty cells. Without the first line of escapeCsvField, you would get the string “null” instead.
Make the delimiter a parameter
Excel installs in many European locales expect a semicolon, because the comma is already the decimal separator there. Make the delimiter a parameter and include it in the test that decides whether to quote:
function escapeCsvField(value, delimiter = ",") {
if (value === null || value === undefined) return "";
const str = String(value);
const needsQuotes = str.includes(delimiter) || /["\r\n]/.test(str);
return needsQuotes ? '"' + str.replace(/"/g, '""') + '"' : str;
}
Nested objects flatten into dot-key columns
Real API responses are rarely flat. The usual approach is to flatten nested objects into dotted column names, so customer.address.city becomes one column. Arrays are the awkward part, because they do not map to columns at all. The flattener below serializes them as JSON strings inside the cell.
function flatten(obj, prefix = "", out = {}) {
for (const [key, value] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${key}` : key;
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
flatten(value, path, out);
} else if (Array.isArray(value)) {
out[path] = JSON.stringify(value);
} else {
out[path] = value;
}
}
return out;
}
function jsonToCsv(rows) {
const flat = rows.map((r) => flatten(r));
const headers = [...new Set(flat.flatMap((r) => Object.keys(r)))];
const lines = [headers.map(escapeCsvField).join(",")];
for (const row of flat) {
lines.push(headers.map((h) => escapeCsvField(row[h])).join(","));
}
return lines.join("\r\n");
}
const orders = [
{ id: 101, customer: { name: "Ada", address: { city: "London" } }, tags: ["vip", "new"] },
{ id: 102, customer: { name: "Grace", address: { city: "New York", zip: "10001" } }, tags: [] },
];
console.log(jsonToCsv(orders));
Output:
id,customer.name,customer.address.city,tags,customer.address.zip
101,Ada,London,"[""vip"",""new""]",
102,Grace,New York,[],10001
Build the header list from the union of keys across all rows. That is why customer.address.zip appears even though the first order has no zip. tags is stored as a JSON string with doubled quotes. It looks noisy in a spreadsheet, but it round-trips cleanly if you later push the file through the CSV to JSON converter, which most ad hoc array formats do not.
If you want a column per array element instead, treat arrays like objects and let the flattener produce tags.0, tags.1 and so on. That works for short, fixed-length arrays. For variable-length arrays the columns multiply quickly, and the JSON-string approach is easier to work with.
Write the CSV file in Node.js with a BOM for Excel
In Node, read the JSON, convert it, and write it with fs/promises. If the file is going to Excel, prefix it with a UTF-8 byte order mark (BOM). Without the BOM, Excel on Windows often guesses the wrong encoding and shows accented characters as garbage.
import { readFile, writeFile } from "node:fs/promises";
const data = JSON.parse(await readFile("./users.json", "utf8"));
const csv = "" + jsonToCsv(data);
await writeFile("./users.csv", csv, "utf8");
console.log(`Wrote ${data.length} rows to users.csv`);
Save this as a .mjs file or set "type": "module" in package.json so top-level await works. For very large inputs, avoid building one giant string. Stream the rows with fs.createWriteStream() and write each line as you produce it.
Download the CSV in the browser with a Blob
Wrap the string in a Blob, create an object URL, and click a temporary link.
function downloadCsv(csvString, filename = "export.csv") {
const blob = new Blob(["" + csvString], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
downloadCsv(jsonToCsv(users), "users.csv");
Call URL.revokeObjectURL() after the click so the browser can free the memory. The download button in our JSON to CSV tool works the same way. With a workbook in the Blob instead of text, the same pattern produces an .xlsx file, which is what the JSON to Excel converter does.
Use a library for streaming and custom columns
Hand-written code covers most cases. Two libraries cover the rest: streaming, custom quoting rules, type coercion, and files too big to hold in memory.
PapaParse unparses in Node and the browser
PapaParse is best known for parsing CSV, but Papa.unparse() goes the other way, and it works in Node and the browser.
import Papa from "papaparse";
const csv = Papa.unparse(users, {
quotes: false, // only quote when needed
delimiter: ",",
newline: "\r\n",
});
Pass { fields: [...], data: [...] } instead of an array of objects if you want to control column order explicitly.
json2csv handles nested paths for you
@json2csv/plainjs only does JSON to CSV. It handles nested paths for you, along with custom field labels and transforms.
import { Parser } from "@json2csv/plainjs";
const parser = new Parser({
fields: ["id", "customer.name", { label: "City", value: "customer.address.city" }],
});
const csv = parser.parse(orders);
It also ships a streaming parser for files that do not fit in memory. Both packages need to be installed with npm, so the snippets above were not run here.
Comparison of JSON to CSV approaches
| Approach | Nested objects | Streaming | Dependency | Best for |
|---|---|---|---|---|
| Plain function | Manual flatten | Manual | None | Small exports, full control |
| PapaParse | No (flatten first) | Yes (parse side) | ~45 KB | Apps that already parse CSV |
| json2csv | Yes, via paths | Yes | Small | Reports with custom columns |
| Online tool | Serialized as JSON | No | None | One-off files |
If your app does not already depend on PapaParse, start with the plain function. Pull in json2csv once you need custom column labels. Shipping about 45 KB for a single unparse call is hard to justify.
Common JSON to CSV mistakes
Most broken CSV exports come down to one of these:
- No quoting: joining with
,and never quoting breaks the file at the first address with a comma. - Bare line feeds: some older Windows tools read
\nalone as one long line, so\r\nis the safe default. - Stringified undefined:
String(undefined)is"undefined", so convert null-ish values to empty strings before you stringify anything. - First-row headers: if a later object has extra keys, those columns disappear without an error, so build the union.
- Missing BOM: Excel shows mojibake without one. Add
when the file is for Excel, and leave it out for programmatic consumers, some of which treat it as part of the first header name.
If this is more than the job needs, the JSON to CSV converter does the escaping for you.