Skip to content
JSON to CSV

7 min read

How to Convert JSON to CSV in JavaScript and Node.js

No library needed. A short function collects headers, escapes values per RFC 4180, and joins everything with commas. This guide starts with the five-line version, then adds support for ragged keys, nested objects, browser downloads, and Node.js streaming for large files.

The simple case: flat array of objects

If every value in every record is a string, number, boolean, or null — no nested objects, no arrays — the conversion is straightforward. Take the keys from the first row as headers, map each row to a comma-separated line, and join.

Input JSON
[
  { "id": 1, "name": "Ada Lovelace", "email": "ada@example.com" },
  { "id": 2, "name": "Grace Hopper", "email": "grace@example.com" },
  { "id": 3, "name": "Hedy Lamarr",  "email": "hedy@example.com" }
]
jsonToCsv.ts
function jsonToCsv(data: Record<string, unknown>[]) {
  const headers = Object.keys(data[0]);
  const rows = data.map((row) =>
    headers.map((h) => {
      const cell = String(row[h] ?? "");
      // RFC 4180: quote if the value contains a comma, quote, or newline
      return /[",\n\r]/.test(cell)
        ? `"${cell.replace(/"/g, '""')}"`
        : cell;
    }).join(",")
  );
  return [headers.join(","), ...rows].join("\n");
}
Output CSV
id,name,email
1,Ada Lovelace,ada@example.com
2,Grace Hopper,grace@example.com
3,Hedy Lamarr,hedy@example.com

The escape logic inside the map handles the three cases that break raw CSV: commas, double quotes, and newlines inside a value. Per RFC 4180, you wrap the value in double quotes and double any existing quotes. Skip this step and you get silently corrupted output the moment someone has a comma in their name.

Handling records with different keys

Real-world JSON is ragged. An API adds a deprecated_at field starting at record 200, or one object has phone while the rest don't. Taking headers from data[0] silently drops every key that first appears later.

Fix: iterate all records and collect the union of keys before writing any rows.

jsonToCsv.ts — ragged-safe
function jsonToCsv(data: Record<string, unknown>[]) {
  // Collect the union of all keys, preserving first-seen order
  const headerSet = new Set<string>();
  for (const row of data) {
    for (const key of Object.keys(row)) headerSet.add(key);
  }
  const headers = [...headerSet];

  const escape = (val: unknown): string => {
    const s = val == null ? "" : String(val);
    return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
  };

  const rows = data.map((row) =>
    headers.map((h) => escape(row[h])).join(",")
  );
  return [headers.join(","), ...rows].join("\n");
}

Set preserves insertion order in JavaScript, so columns appear in the order they are first encountered. Missing values become empty cells because row[h] returns undefined, which the escape function converts to an empty string.

Nested objects: flatten first

When a record contains { address: { city: "London" } }, calling String() on the value produces [object Object] — useless in a spreadsheet. The fix is to flatten each record before converting, turning nested keys into dot-notation columns like address.city.

flatten.ts
function flatten(
  obj: Record<string, unknown>,
  prefix = "",
  out: Record<string, unknown> = {}
): Record<string, unknown> {
  for (const [key, val] of Object.entries(obj)) {
    const path = prefix ? `${prefix}.${key}` : key;
    if (val && typeof val === "object" && !Array.isArray(val)) {
      flatten(val as Record<string, unknown>, path, out);
    } else if (Array.isArray(val)) {
      out[path] = val.map(String).join("; ");
    } else {
      out[path] = val;
    }
  }
  return out;
}

// Usage: const flat = data.map((row) => flatten(row));
// Then pass `flat` to jsonToCsv()

Arrays are joined with a semicolon so they stay in a single cell. For more strategies — row expansion, JSON cells, and when each makes sense — see the nested JSON to CSV guide.

Triggering a download in the browser

Once you have the CSV string, create a Blob, generate an object URL, and click a hidden anchor element. The file downloads without any server involvement.

download.ts
function downloadCsv(csv: string, filename = "data.csv") {
  const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);
}

If the CSV will be opened in Excel, prepend the UTF-8 BOM so non-ASCII characters display correctly:

BOM prefix
// Prepend the UTF-8 BOM so Excel opens the file correctly
const BOM = "\uFEFF";
const blob = new Blob([BOM + csv], { type: "text/csv;charset=utf-8;" });

Skip the code entirely

For one-off conversions, paste your JSON into the free converter — it handles nesting, arrays, and ragged keys in the browser with no upload and no size limit.

Open the JSON to CSV converter

Fetching an API and exporting to CSV

A common use case: pull data from a REST API and let the user download it as a spreadsheet. Combine fetch with the functions above:

fetch-and-download.ts
const res = await fetch("https://api.example.com/users");
const data = await res.json();
const csv = jsonToCsv(data);
downloadCsv(csv, "users.csv");

This works in any modern browser. For authenticated APIs, add headers to the fetch call — the CSV conversion side stays the same.

Node.js: reading and writing files

The conversion function is identical. Only I/O changes — use fs/promises instead of Blob URLs:

convert.mjs
import { readFile, writeFile } from "node:fs/promises";

const raw = await readFile("data.json", "utf-8");
const data = JSON.parse(raw);
const csv = jsonToCsv(data);            // reuse the function above
await writeFile("data.csv", csv);

For files that fit in memory (up to a few hundred MB), this is all you need.

Streaming large files in Node.js

When the JSON file is multiple gigabytes, loading it all into memory is not an option. If the data is formatted as one object per line (NDJSON / JSON Lines), you can stream it through a Transform:

stream.mjs
import { createReadStream, createWriteStream } from "node:fs";
import { Transform } from "node:stream";
import { pipeline } from "node:stream/promises";

let headers: string[] | null = null;
let buffer = "";

const toCSV = new Transform({
  readableObjectMode: false,
  writableObjectMode: false,
  transform(chunk, _enc, cb) {
    buffer += chunk.toString();
    const lines = buffer.split("\n");
    buffer = lines.pop()!;              // keep incomplete last line
    for (const line of lines) {
      const trimmed = line.trim().replace(/,\s*$/, "");
      if (!trimmed || trimmed === "[" || trimmed === "]") continue;
      const row = JSON.parse(trimmed);
      if (!headers) {
        headers = Object.keys(row);
        this.push(headers.join(",") + "\n");
      }
      this.push(headers.map((h) => escape(row[h])).join(",") + "\n");
    }
    cb();
  },
});

await pipeline(
  createReadStream("huge.json", "utf-8"),
  toCSV,
  createWriteStream("huge.csv")
);

Memory usage stays constant regardless of file size. The trade-off is that the input must be line-delimited — a single huge JSON array requires a streaming JSON parser like stream-json or jsonparse instead.

Common pitfalls

  • Skipping RFC 4180 quoting. If you join values with commas without escaping, the first record with a comma in a field shifts every column to the right. Always quote values that contain , " or newlines.
  • null vs undefined. String(null) returns "null" and String(undefined) returns "undefined" — both pollute CSV cells. Check for both and convert to an empty string.
  • Dates. JSON has no date type. If a field contains 2026-08-10T14:30:00Z, it passes through as a string, which is usually what you want. Do not call new Date() on it unless you need a specific format — you lose timezone information.
  • Large numbers. JavaScript numbers are IEEE 754 doubles. IDs longer than 15 digits (e.g. 9007199254740993) lose precision on JSON.parse. If your JSON contains big integers, parse them as strings with a reviver or a library like lossless-json.
  • Line endings. Use \n (LF) for general-purpose CSVs. If the file will be opened directly in Excel on Windows, \r\n (CRLF) avoids the occasional rendering glitch. The JSON to Excel converter on this site sets CRLF and BOM automatically.

Which approach to use

Write the function yourself when you need full control — custom column order, computed fields, or integration into an existing codebase. Use the browser converter when you want the result in ten seconds without touching code. Working in Python instead? The Python guide covers the same ground with csv.DictWriter and pandas.

JSON to CSV in JavaScript: common questions

Can I convert JSON to CSV entirely in the browser without a server?
Yes. JavaScript runs natively in the browser, so you can parse JSON, build the CSV string, and trigger a download with a Blob URL — no server roundtrip needed. The converter on this site works exactly this way: your data never leaves the tab.
How do I handle nested JSON objects in JavaScript?
Recursively flatten each object before converting. Walk every key: if the value is a plain object, recurse with a dot-separated prefix (e.g. "address.city"); if it is an array, join the elements into a delimited string. The flatten() function in this guide does both.
What about very large JSON files — will the browser run out of memory?
A JSON array must be fully parsed before you can iterate it, so browser memory is the limit — typically a few hundred MB. In Node.js you can stream line-delimited JSON (NDJSON) through a Transform stream and write CSV incrementally, handling files of any size with constant memory.
Do I need a library like PapaParse to convert JSON to CSV?
Not usually. PapaParse is designed for parsing CSV, not generating it. For JSON-to-CSV, a 15-line function that collects headers, escapes values per RFC 4180, and joins with commas covers nearly every case. A library adds value mainly if you also need to read CSV back.
How do I make Excel open the CSV correctly?
Prepend the UTF-8 BOM (byte order mark) "\uFEFF" to the CSV string before creating the Blob. Without it, Excel on Windows may mangle non-ASCII characters. The BOM is invisible in text editors but tells Excel the encoding is UTF-8.
What is the difference between JSON.parse and JSON.stringify for CSV conversion?
JSON.parse converts a JSON string into a JavaScript object — you need it to read the input. JSON.stringify goes the other direction (object to JSON string) and is not used in CSV conversion. To build a CSV string, map over the parsed array and join values with commas.