JSON is easy for programs to read and awkward for people. A spreadsheet wants
rows and columns, and an API response is a list of records that often has
records inside records. If every record is flat, one jq command makes the
CSV. If they are nested, you decide which fields become columns.
A JSON file converted in the browser, nested fields as their own columns.
jq comes with recent macOS (/usr/bin/jq) and is one install away elsewhere
(sudo apt install jq, winget install jqlang.jq). For an array of flat
records:
jq -r '(map(keys_unsorted) | add | reduce .[] as $c ([]; if index([$c]) then . else . + [$c] end)) as $k
| $k, (.[] | [.[$k[]]]) | @csv' records.json > records.csv
The first line collects every key that appears in any record, in the order it first appears. The second writes that header and then one row per record.
The example file
Every command here was run on this file, a short customer list of the kind a
REST API returns. It has a quote and a line break inside values, a null, a
nested address, a tags array, and a phone that only the last record has.
[
{
"id": 10482,
"name": "Ana Lima",
"email": "[email protected]",
"postcode": "01234",
"signed_up": "2026-03-14",
"active": true,
"note": "Prefers email, not phone",
"address": { "street": "Rua Augusta 12", "city": "Lisbon", "country": "PT" },
"tags": ["vip", "newsletter"]
},
{
"id": 10483,
"name": "Ben \"Benny\" Okafor",
"note": "Two lines:\nask for the invoice",
"address": { "street": "4 Main St", "city": "Cambridge", "country": "US" },
"tags": []
},
{
"id": 10484,
"name": "Chloé Martin",
"note": null,
"address": { "street": "8 rue Oberkampf", "city": "Paris", "country": "FR" },
"tags": ["trial"],
"phone": "+33 6 12 34 56 78"
}
]
(The second and third records also have email, postcode, signed_up and
active; they are left out here to save space.)
Flat records: one command
The command you usually see online takes the column names from the first record:
jq -r '(.[0] | keys_unsorted) as $k | $k, (.[] | [.[$k[]]]) | @csv' records.json
That works until one record has a field the first one doesn't. With address
and tags removed from the example, it writes every row and quietly drops
phone, because Ana has no phone. The command in the box above reads the keys
from every record, so the header gets phone and the rows without one get an
empty cell:
"id","name","email","postcode","signed_up","active","note","phone"
10482,"Ana Lima","[email protected]","01234","2026-03-14",true,"Prefers email, not phone",
10483,"Ben ""Benny"" Okafor","[email protected]","02139","2026-05-02",false,"Two lines:
ask for the invoice",
10484,"Chloé Martin","[email protected]","75011","2026-07-21",true,,"+33 6 12 34 56 78"
@csv does the quoting properly: the quote in Ben's name is doubled, the line
break stays inside a quoted cell, and null becomes an empty cell. Read back
with Python's csv module, that is four rows, with Ben's note as one value on
two lines.
To open the CSV in Excel without losing the zero in 01234 or getting the
dates turned round, see how to convert CSV to Excel without breaking the
data.
Nested records: choose the columns
Run the first-record command on the full example and jq writes the header,
then stops at the first nested value (the command in the box stops the same
way):
"id","name","email","postcode","signed_up","active","note","address","tags"
jq: error (at customers.json:36): object ({"street":"...) is not valid in a csv row
A CSV cell holds one value, so an object or an array has to become something
else first. For a spreadsheet a person will read, the best answer is usually to
pick the fields that matter. A path like .address.city reaches into the
nested object, and join turns an array into one cell:
jq -r '["id","name","email","city","country","tags"],
(.[] | [.id, .name, .email, .address.city, .address.country, (.tags | join(", "))])
| @csv' customers.json > customers.csv
"id","name","email","city","country","tags"
10482,"Ana Lima","[email protected]","Lisbon","PT","vip, newsletter"
10483,"Ben ""Benny"" Okafor","[email protected]","Cambridge","US",""
10484,"Chloé Martin","[email protected]","Paris","FR","trial"
Ben's empty tags array becomes an empty cell. Six named columns beat
a dozen generated ones when the file is going to someone in sales.
Every field, straight to Excel: pandas
If you want every field as a column and an .xlsx at the end, Python's pandas
does both. json_normalize turns address into address.street,
address.city and address.country. It leaves arrays alone, so join them
yourself:
import json, pandas as pd
rows = json.load(open("customers.json", encoding="utf-8"))
df = pd.json_normalize(rows) # address -> address.street, address.city, ...
df["tags"] = df["tags"].str.join(", ") # ["vip", "newsletter"] -> "vip, newsletter"
df["id"] = df["id"].astype(str) # keep IDs as text
df.to_excel("customers.xlsx", index=False)
It needs pip install pandas openpyxl. We opened the result with openpyxl to
check it: twelve columns (id, name, email, postcode, signed_up,
active, note, tags, address.street, address.city, address.country,
phone), the postcodes stored as text with their zeros, active as real
TRUE/FALSE values, and Ben's note on two lines in one cell.
Leave out the tags line and it still runs, but the cells hold Python's
spelling of the list, ['vip', 'newsletter'], and Ben's says [].
mlr --ijson --ocsv flatten then unsparsify customers.json flattens
everything with no script (Miller 6.13). Arrays become numbered columns
(tags.1, tags.2), an empty array becomes a separate tags column holding
[], and null is written as the word null. Leave out unsparsify and it
stops at the first record with different fields: mlr: CSV schema change.
Long numbers and IDs
Excel keeps 15 significant digits. A 16-digit order number or a database ID
past 9007199254740991 doesn't survive being a number, and JavaScript, which
every browser tool uses to read JSON, has the same limit. Given
{"id": 9007199254740993}, JSON to XLSX and pandas both wrote
9007199254740992, one less than the real ID, and so does JSON to CSV. jq
kept it, and (.id | tostring) in a jq command writes it as text, so Excel
keeps it too. Below that limit the browser tools keep every digit: a 13-digit
barcode 4006381333931 and a millisecond timestamp 1760745600000 come out of
JSON to CSV as written.
If the IDs are yours to change, make them strings in the JSON. If they come
from an API, convert them to text in the script (the astype(str) line above)
before anyone opens the file.
In the browser
The two converters read the JSON the same way: each record is a row, each key
is a column, and keys that only some records have still get a column, with
empty cells where they are missing. Nested objects become dotted columns
(address.street, address.city, address.country), and a list goes into
one cell as its JSON text. Numbers stay numbers, true and false become TRUE
and FALSE, and null is an empty cell. An array of arrays works too: its first
row is the header.
The example file, through JSON to CSV:
id,name,email,postcode,signed_up,active,note,address.street,address.city,address.country,tags,phone
10482,Ana Lima,[email protected],01234,2026-03-14,TRUE,"Prefers email, not phone",Rua Augusta 12,Lisbon,PT,"[""vip"",""newsletter""]",
10483,"Ben ""Benny"" Okafor",[email protected],02139,2026-05-02,FALSE,"Two lines:
ask for the invoice",4 Main St,Cambridge,US,[],
10484,Chloé Martin,[email protected],75011,2026-07-21,TRUE,,8 rue Oberkampf,Paris,FR,"[""trial""]",+33 6 12 34 56 78
Twelve columns, the same twelve pandas makes, in the order the keys first
appear. The lists read ["vip","newsletter"] rather than vip, newsletter:
if that matters, join them with jq or pandas instead.
- Open JSON to XLSX (or JSON to
CSV) and drop your
.jsonon the page, or choose it. The file must be an array: of records,[{...}, {...}], or of rows,[[...], [...]]. - Click Convert 1 File.
- Under Converted Files, click Download.
The same file as a CSV.
Which to use
| Your JSON | Best route | Why |
|---|---|---|
| Flat records, you have a terminal | jq (box above) |
One command, exact numbers, proper quoting |
| No terminal, or a phone | JSON to XLSX | Nothing to install; nested fields get columns; text stays text |
| Nested, for a person to read | jq with named fields |
You choose the columns, nothing else |
| Nested, every field as a column | JSON to XLSX, or pandas json_normalize |
Both give address.city its own column; pandas lets you join lists your way |
| IDs longer than 15 digits | jq with tostring, or pandas astype(str) |
Keeps them as text |
Excel can also open JSON directly, through Power Query (Get Data), on
Windows and in Microsoft 365 for Mac: see Microsoft's Import and shape data in
Excel for Mac.
Nested records show as Record and List cells that you expand column by
column. That suits a file you will refresh every week more than one you open
once.
When you have the file, open it once yourself before sending it on: check that a long ID or a postcode in the first rows looks the way it does in the JSON.