JSON is great for APIs and configs. CSV is great for spreadsheets, databases, and data analysis tools. Converting between them is a daily task for data engineers and backend developers alike — but the conversion has some sharp edges that trip people up.
Why JSON-to-CSV is harder than it looks
JSON supports nested objects and arrays. CSV is flat. That mismatch is the source of every problem:
[
{
"id": 1,
"name": "Alice",
"address": { "city": "Berlin", "zip": "10115" },
"tags": ["dev", "admin"]
}
]
How do you represent address.city and tags in a CSV row? There's no single correct answer — you have to choose a strategy before writing any code.
Three strategies for nested data
| Strategy | How it works | Best for |
|---|---|---|
| Flatten with dot notation | address.city becomes a column header |
Nested objects, shallow nesting |
| Stringify arrays | ["dev","admin"] → "dev,admin" in one cell |
Arrays with a small number of items |
| One row per array item | Each tag gets its own row (like a JOIN) | Arrays that need to be queried individually |
For most export-to-spreadsheet use cases, flatten objects and stringify arrays.
The conversion algorithm
The simplest correct approach:
- Ensure input is an array of objects (or wrap a single object in an array)
- Collect all unique keys across every object (some may have extra/missing fields)
- Build a header row from those keys
- For each object, build a data row: if a key is missing, use an empty string
- Escape values that contain commas, quotes, or newlines
- Join rows with
\n
CSV quoting rules
A value must be wrapped in double quotes if it contains:
- A comma (
,) - A double quote (
") — which must also be doubled:""inside a quoted field - A newline (
\nor\r\n)
hello world → hello world (no quoting needed)
hello, world → "hello, world" (contains comma)
say "hi" → "say ""hi""" (contains quote)
line 1\nline 2 → "line 1\nline 2" (contains newline)
JavaScript
Flat arrays of objects (no nesting):
function jsonToCsv(rows) {
if (!rows.length) return "";
// Collect all unique keys
const keys = [...new Set(rows.flatMap(Object.keys))];
const escape = (val) => {
const str = val == null ? "" : String(val);
return /[",\n\r]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str;
};
const header = keys.map(escape).join(",");
const body = rows.map(row =>
keys.map(k => escape(row[k])).join(",")
);
return [header, ...body].join("\n");
}
// Usage
const data = [
{ id: 1, name: "Alice", role: "admin" },
{ id: 2, name: "Bob" }, // missing 'role' — handled
{ id: 3, name: 'Say "hello"', role: "user" },
];
console.log(jsonToCsv(data));
// id,name,role
// 1,Alice,admin
// 2,Bob,
// 3,"Say ""hello""",user
With dot-notation flattening for nested objects:
function flatten(obj, prefix = "", result = {}) {
for (const [key, val] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (val !== null && typeof val === "object" && !Array.isArray(val)) {
flatten(val, fullKey, result);
} else {
result[fullKey] = Array.isArray(val) ? val.join(";") : val;
}
}
return result;
}
const nested = [
{ id: 1, address: { city: "Berlin", zip: "10115" }, tags: ["dev", "admin"] },
{ id: 2, address: { city: "Paris", zip: "75001" }, tags: ["user"] },
];
const csv = jsonToCsv(nested.map(row => flatten(row)));
// id,address.city,address.zip,tags
// 1,Berlin,10115,dev;admin
// 2,Paris,75001,user
To trigger a download in the browser:
function downloadCsv(csv, filename = "export.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);
}
Python
Python's built-in csv module handles all quoting automatically:
import csv
import io
import json
def json_to_csv(data: list[dict]) -> str:
if not data:
return ""
# Collect all unique keys (preserving insertion order)
keys = list(dict.fromkeys(k for row in data for k in row))
output = io.StringIO()
writer = csv.DictWriter(
output,
fieldnames=keys,
extrasaction="ignore", # skip keys not in fieldnames
restval="", # fill missing fields with ""
lineterminator="\n",
)
writer.writeheader()
writer.writerows(data)
return output.getvalue()
# Example with flattening
def flatten(obj: dict, prefix: str = "") -> dict:
result = {}
for key, val in obj.items():
full_key = f"{prefix}.{key}" if prefix else key
if isinstance(val, dict):
result.update(flatten(val, full_key))
elif isinstance(val, list):
result[full_key] = ";".join(str(v) for v in val)
else:
result[full_key] = val
return result
with open("data.json") as f:
rows = json.load(f)
flat_rows = [flatten(row) for row in rows]
csv_text = json_to_csv(flat_rows)
with open("output.csv", "w", newline="", encoding="utf-8") as f:
f.write(csv_text)
Using pandas (simpler, but adds a dependency):
import pandas as pd
import json
with open("data.json") as f:
data = json.load(f)
# pandas.json_normalize flattens nested objects automatically
df = pd.json_normalize(data)
df.to_csv("output.csv", index=False)
json_normalize also handles arrays-of-objects in nested fields via the record_path parameter.
Go
package main
import (
"encoding/csv"
"encoding/json"
"os"
)
// flattenJSON flattens a map with dot-notation keys (one level deep shown here)
func flattenJSON(obj map[string]any, prefix string) map[string]string {
result := make(map[string]string)
for k, v := range obj {
key := k
if prefix != "" {
key = prefix + "." + k
}
switch val := v.(type) {
case map[string]any:
for fk, fv := range flattenJSON(val, key) {
result[fk] = fv
}
case []any:
parts := make([]string, len(val))
for i, item := range val {
parts[i] = fmt.Sprintf("%v", item)
}
result[key] = strings.Join(parts, ";")
default:
if val == nil {
result[key] = ""
} else {
result[key] = fmt.Sprintf("%v", val)
}
}
}
return result
}
func jsonToCSV(jsonData []byte, outFile string) error {
var rows []map[string]any
if err := json.Unmarshal(jsonData, &rows); err != nil {
return err
}
// Flatten and collect all keys
flat := make([]map[string]string, len(rows))
keySet := make(map[string]struct{})
for i, row := range rows {
flat[i] = flattenJSON(row, "")
for k := range flat[i] {
keySet[k] = struct{}{}
}
}
// Stable key order
keys := make([]string, 0, len(keySet))
for k := range keySet {
keys = append(keys, k)
}
sort.Strings(keys)
f, err := os.Create(outFile)
if err != nil {
return err
}
defer f.Close()
w := csv.NewWriter(f)
defer w.Flush()
if err := w.Write(keys); err != nil {
return err
}
for _, row := range flat {
record := make([]string, len(keys))
for i, k := range keys {
record[i] = row[k] // empty string if key absent
}
if err := w.Write(record); err != nil {
return err
}
}
return w.Error()
}
Go's encoding/csv handles quoting automatically — you never need to escape values yourself.
PHP
function jsonToCsv(array $rows): string {
if (empty($rows)) return '';
// Collect all unique keys
$keys = [];
foreach ($rows as $row) {
foreach (array_keys($row) as $k) {
$keys[$k] = true;
}
}
$keys = array_keys($keys);
$handle = fopen('php://temp', 'r+');
fputcsv($handle, $keys);
foreach ($rows as $row) {
$record = array_map(
fn($k) => isset($row[$k])
? (is_array($row[$k]) ? implode(';', $row[$k]) : (string)$row[$k])
: '',
$keys
);
fputcsv($handle, $record);
}
rewind($handle);
$csv = stream_get_contents($handle);
fclose($handle);
return $csv;
}
// Flatten nested objects
function flattenRow(array $row, string $prefix = ''): array {
$result = [];
foreach ($row as $key => $val) {
$fullKey = $prefix !== '' ? "{$prefix}.{$key}" : $key;
if (is_array($val) && array_is_list($val)) {
$result[$fullKey] = implode(';', $val);
} elseif (is_array($val)) {
$result = array_merge($result, flattenRow($val, $fullKey));
} else {
$result[$fullKey] = $val ?? '';
}
}
return $result;
}
// Usage
$json = file_get_contents('data.json');
$rows = json_decode($json, true);
$flat = array_map('flattenRow', $rows);
$csv = jsonToCsv($flat);
// Stream to browser
header('Content-Type: text/csv');
header('Content-Disposition: attachment; filename="export.csv"');
echo $csv;
fputcsv() handles quoting automatically, just like Go's csv.Writer.
Handling common edge cases
| Situation | What to do |
|---|---|
null values |
Output as empty string |
| Boolean values | true/false as literal strings |
| Numbers with many decimals | Pass through as-is (CSV preserves precision) |
| Unicode characters | Use UTF-8 encoding; add BOM (\xEF\xBB\xBF) for Excel |
| Empty array | Return empty string or just the header row |
| Deeply nested objects | Flatten recursively; cap depth to avoid key explosion |
| Arrays of objects inside a row | Decide: stringify as JSON, join IDs, or skip |
Excel and the BOM
Excel on Windows doesn't correctly read UTF-8 CSVs unless the file starts with a UTF-8 BOM (\xEF\xBB\xBF). Add it as the first bytes of the output when you know the file is destined for Excel:
const BOM = "\uFEFF";
const csv = BOM + jsonToCsv(data);
Convert JSON to CSV online
If you have a JSON file and just need a quick CSV — no code required — the JSON to CSV tool converts it in your browser. Paste your JSON array, click Convert, and download the CSV. The tool flattens one level of nesting automatically and handles missing keys.
FAQ
Q: What if my JSON isn't an array — it's a single object?
Wrap it: [obj]. Most converters expect an array of rows.
Q: How should I handle arrays of objects inside a row (e.g., "orders": [{...}, {...}])?
Three options: (1) skip them, (2) serialize the whole sub-array as a JSON string in one cell, or (3) produce multiple rows. Which is correct depends on how you'll use the CSV.
Q: Does column order matter? CSV has no enforced column order, but tools like pandas and spreadsheet apps rely on the header row. Always output the header and keep it consistent.
Q: My numbers are turning into scientific notation in Excel. How do I fix that? Excel auto-formats cells that look like numbers. Prefix with a single quote in the formula bar, or import via "Text Import Wizard" and mark the column as Text. Alternatively, prefix the value with a tab character — a hack, but it works.
Q: How do I handle a 500 MB JSON file without loading it all into memory?
Stream it. In Node.js use JSONStream or stream-json. In Python use ijson. Read and emit CSV rows one at a time instead of building the full output in memory.
Q: Is there a standard for how CSVs should look? RFC 4180 defines the most widely used format: comma delimiter, CRLF line endings, optional header row, double-quote escaping. Most modern tools also accept LF-only endings and tab delimiters (TSV).