Quick answer: Converting JSON to CSV is two steps: flatten each object to a single level of dotted keys (
customer.address.city), then write one row per object with a header built from the union of all keys. In Python that ispd.json_normalize(data).to_csv("out.csv", index=False); in jq it isjq -r '.[] | [.id, .name] | @csv'; in Excel it is Data → Get Data → From JSON. Arrays are the only real decision: join them, explode them into extra rows, or keep them as JSON text.
Finance asks for the orders export "as a spreadsheet." The API hands you a 20 MB JSON array where every order has a nested customer object and a list of line items. Open it in Excel and you get one unreadable column. This guide turns that file into a clean CSV, with the nested parts handled on purpose rather than by accident.
Try the JSON to CSV Converter on a sample array, then check the exported columns and values before using the file.
Why Nested JSON Is the Hard Part
CSV is a grid: every row has the same columns, every cell holds one value. JSON is a tree. Flat JSON objects map to rows trivially, so the entire difficulty of JSON to CSV conversion is deciding what to do with the two kinds of nesting. Take this order:
[
{
"id": 1001,
"customer": { "name": "Priya", "address": { "city": "Pune", "zip": "411001" } },
"items": [
{ "sku": "A-100", "qty": 2 },
{ "sku": "B-220", "qty": 1 }
],
"tags": ["gift", "express"]
}
]
Nested objects are easy. Join the path with a dot and the object becomes columns: customer.name, customer.address.city, customer.address.zip. Every tool below does this, and the flatten nested JSON guide covers the edge cases such as keys that already contain dots.
Arrays are the decision point. A cell cannot hold a list, so you have to choose:
| Strategy | Result | Use when |
|---|---|---|
| Join with a delimiter | tags -> "gift; express" | Short lists of scalars you only need to read |
| Explode into rows | One row per line item, parent columns repeated | You will sum, filter, or pivot the items |
| Index into columns | items.0.sku, items.1.sku, ... | Fixed, small arrays of the same length |
| Keep as JSON text | items -> "[{\"sku\":\"A-100\"}]" | You need to round-trip back to JSON later |
A sensible default: join scalar arrays like tags, explode arrays of objects like items. With that decision made, the sample order becomes two rows:
id,customer.name,customer.address.city,customer.address.zip,tags,sku,qty
1001,Priya,Pune,411001,gift; express,A-100,2
1001,Priya,Pune,411001,gift; express,B-220,1
Method 1: Python With pandas json_normalize
pd.json_normalize flattens nested objects into dotted columns in one call. It is the fastest route if pandas is already installed:
import json
import pandas as pd
with open("orders.json", encoding="utf-8") as f:
data = json.load(f)
df = pd.json_normalize(data, sep=".")
df.to_csv("orders.csv", index=False, encoding="utf-8-sig")
By default arrays are left as Python lists, which pandas writes using their repr, so the tags column would read ['gift', 'express']. That is rarely what you want. To explode items into rows and carry the parent fields along, use record_path and meta:
df = pd.json_normalize(
data,
record_path="items",
meta=["id", ["customer", "name"], ["customer", "address", "city"]],
sep=".",
)
# columns: sku, qty, id, customer.name, customer.address.city
If you cannot add a dependency, the standard library handles it with a short recursive flattener and csv.DictWriter. Note the newline="" argument, which prevents blank lines between rows on Windows:
import csv
import json
def flatten(obj, prefix=""):
out = {}
for key, value in obj.items():
path = f"{prefix}.{key}" if prefix else key
if isinstance(value, dict):
out.update(flatten(value, path))
elif isinstance(value, list):
scalars = all(not isinstance(v, (dict, list)) for v in value)
out[path] = "; ".join(map(str, value)) if scalars else json.dumps(value)
else:
out[path] = value
return out
with open("orders.json", encoding="utf-8") as f:
rows = [flatten(order) for order in json.load(f)]
headers = list(dict.fromkeys(k for row in rows for k in row))
with open("orders.csv", "w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=headers)
writer.writeheader()
writer.writerows(rows)
The dict.fromkeys line builds the header from every key seen in any row, in first-seen order. Objects that lack a key simply get an empty cell, which is the behaviour you want when records are ragged.
Method 2: Node.js (With and Without a Library)
Node has no CSV module in the standard library, but the whole job is about thirty lines. The two functions that matter are the flattener and the cell quoter:
import fs from "node:fs";
const data = JSON.parse(fs.readFileSync("orders.json", "utf8"));
function flatten(obj, prefix = "", out = {}) {
for (const [key, value] of Object.entries(obj)) {
const path = prefix ? prefix + "." + key : key;
if (Array.isArray(value)) {
const scalars = value.every((v) => v === null || typeof v !== "object");
out[path] = scalars ? value.join("; ") : JSON.stringify(value);
} else if (value !== null && typeof value === "object") {
flatten(value, path, out);
} else {
out[path] = value;
}
}
return out;
}
function cell(value) {
if (value === null || value === undefined) return "";
const s = String(value);
return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}
const rows = data.map((item) => flatten(item));
const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))];
const lines = [headers.join(",")];
for (const row of rows) {
lines.push(headers.map((h) => cell(row[h])).join(","));
}
fs.writeFileSync("orders.csv", "\uFEFF" + lines.join("\r\n"), "utf8");
The regular expression in cell is the entire quoting rule: if a value contains a comma, a double quote, or a line break, wrap it in quotes and double any quotes inside. Everything else is written bare.
With a library, the quoting is handled for you. PapaParse's Papa.unparse(rows) accepts an array of flat objects and returns CSV text, and works in the browser as well as Node. The json2csv package goes further and ships a flatten transform, so nested objects become dotted columns without your own recursion. Either way, decide on the array strategy first; libraries will not guess it for you.
Method 3: Excel Power Query (No Code)
If the person asking for the CSV uses Excel, you can skip the file entirely and load JSON straight into a sheet:
- Open a blank workbook. On the Data tab choose Get Data → From File → From JSON and pick the file.
- The Power Query editor opens showing a List. Click To Table on the ribbon and accept the defaults.
- Click the expand icon (two arrows) in the
Column1header and tick the fields you want. Untick "Use original column name as prefix" if you prefer short names. - Nested objects appear as Record cells with their own expand icon. Expand again to reach
customer.address.city. - Arrays appear as List cells. The expand icon offers Expand to New Rows (explode) or Extract Values with a delimiter (join).
- Click Close & Load. Save as CSV UTF-8 if you need the file.
Power Query remembers the steps, so refreshing the query re-imports an updated JSON file with the same flattening. For a one-off conversion with no code, it is hard to beat. Double-clicking the .json file will not get you there; it opens in a text editor or a browser, never as a table.
Method 4: jq @csv on the Command Line
jq has a built-in @csv formatter. Feed it an array per row, use -r for raw output so the quotes are not escaped, and prepend a header array:
jq -r '["id","customer","city","tags"],
(.[] | [.id, .customer.name, .customer.address.city, (.tags | join("; "))])
| @csv' orders.json
"id","customer","city","tags"
1001,"Priya","Pune","gift; express"
@csv quotes every string and leaves numbers bare, which Excel and pandas both read without complaint. Exploding an array into rows takes one more pipe, saving the parent id in a variable first:
jq -r '.[] | .id as $id | .items[] | [$id, .sku, .qty] | @csv' orders.json
# 1001,"A-100",2
# 1001,"B-220",1
The limitation is that you name the columns by hand. For files with hundreds of keys that is tedious, and pandas is the better tool. For a quick extract of five fields from an API response, jq wins. The jq cheat sheet has the @tsv variant and the other formatters.
Quoting Commas, Quotes, and Newlines
Hand-rolled CSV breaks on the first product description that contains a comma. The rules, as laid out in RFC 4180, are short:
- A field 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:
"". - Rows end with CRLF, though almost every reader accepts a bare LF.
id,description
1,"Cable, 2 m, ""braided"""
2,"Line one
line two"
Row 2 really does span two physical lines; that is legal CSV. Every method above handles this correctly as long as you use the library's writer rather than string concatenation. If a downstream system chokes on multi-line cells, replace newlines with a space before writing.
One more trap: Excel in many European locales uses a semicolon as the list separator, so a comma CSV opens as a single column. Either write the file with sep=";" for those users or have them use Data → From Text/CSV, which lets them pick the delimiter.
The UTF-8 BOM: Making Excel Show Accents Correctly
Write Zürich to a plain UTF-8 CSV, double-click it, and Excel on Windows shows Zürich. Excel guesses a legacy code page unless the file begins with the three-byte UTF-8 byte order mark. That is why the Python examples use encoding="utf-8-sig" and the Node example prepends .
The BOM is a trade-off. Many non-Excel readers treat it as part of the first header, so your id column becomes id and lookups fail. Add the BOM only when a human is going to open the file in Excel; leave it out for files headed to a database import, a data pipeline, or back through the CSV to JSON direction. The UTF-8 and BOM guide shows how to detect and strip it when it sneaks in.
Common Mistakes
- Building the header from the first object only. Later objects with extra keys get silently dropped. Collect keys from every row, as the examples do.
- Writing arrays with
str()in Python. You get['a', 'b'], which is neither JSON nor a clean list. Join scalars or usejson.dumps. - Forgetting
newline=""when opening the file in Python on Windows. The csv module writes its own CRLF, and the text layer adds another CR, so Excel shows a blank row after every record. - Letting Excel mangle values. Zip codes lose leading zeros and long IDs become
1.00123E+15. That is Excel reformatting on open, not a CSV problem; import via From Text/CSV and set those columns to Text. - Starting from invalid JSON. A trailing comma or a stray line break in the source file throws every converter. Paste the input into JSON Prettifier first; the validator gives you the line and column, and Sort Keys makes it easier to spot which fields exist across records.
- Loading a multi-gigabyte file into pandas. If the export is huge, convert it to JSON Lines first and stream one object at a time, writing rows as you go.
Which Method Should You Use?
Use pandas for anything repeatable or with many columns. Use jq for a quick extract of a few fields from an API response. Use Power Query when the consumer lives in Excel and you want them to refresh the data themselves. Use the Node script when the conversion runs inside a JavaScript service. If the real question is whether the data should be CSV at all, the JSON vs CSV comparison walks through when the flat format is the right choice.
Before sharing exports containing untrusted text, run the JSON-to-CSV safety tests. Correct cell quoting does not by itself prevent spreadsheet formula interpretation or guarantee safe save/reopen behavior.