Flattening JSON into rows and columns
How nested JSON maps onto a spreadsheet, which list becomes the main sheet, what Excel's limits cut, and why the .xlsx keeps leading zeros that Excel can drop from a CSV.
Last reviewed · 1,804 words
In short
- Download the .xlsx rather than the CSV when the data holds codes with leading zeros or whole numbers longer than 15 digits. A CSV has no way to mark a cell as text.
- To see which rows of a child sheet belong to which parent, filter on its link column, such as "data #". It holds the parent's number from the # column.
- Tick the option to copy the parent's columns onto child rows when each sheet has to sort and filter on its own.
- The main sheet is the longest list that is not inside another list, and a tie goes to the one found first. Every other list is on its own sheet in the same file.
- When the JSON will not read, go to the line and column shown. A trailing comma is marked at the comma itself.
- A .jsonl or .ndjson file with two or more lines needs no editing first. Each line becomes a row.
You have JSON from an API, an app export or a developer, and someone needs it in Excel. JSON is a tree and a worksheet is a grid, so every converter has to decide what counts as a row, what a nested object becomes and where a list goes. These are the rules the converter above follows, the Excel limits they meet, and how to find the mistake when the JSON will not read.
Nested objects become dotted columns
Take two orders, each with a customer object, a list of tags and a list of items:
[
{
"id": 101,
"customer": { "name": "Asha Rao", "city": "Pune", "pin": "411001" },
"tags": ["new", "priority"],
"items": [
{ "sku": "TSHIRT-M", "qty": 2 },
{ "sku": "MUG-01", "qty": 1 }
]
},
{
"id": 102,
"customer": { "name": "Rahul Mehta", "city": "Jaipur", "pin": "302001" },
"tags": [],
"items": [{ "sku": "SHOES-9", "qty": 1 }]
}
]
A nested object gets no sheet of its own: each field inside it becomes a column named by its path, such as customer.city. The main sheet from that JSON is:
| # | id | customer.name | customer.city | customer.pin | tags |
|---|---|---|---|---|---|
| 1 | 101 | Asha Rao | Pune | 411001 | new; priority |
| 2 | 102 | Rahul Mehta | Jaipur | 302001 |
Columns keep the order they are first met in, so a field that only a later record has goes after the columns already seen. A key containing a dot can collide with a nested one: {"a.b": 1} and {"a": {"b": 2}} both print as a.b, share one column, and the first value in the row is kept.
Lists of plain values share one cell
A list of text or numbers, such as tags, is not worth a sheet. It is joined into one cell with a semicolon and a space, so ["new", "priority"] becomes new; priority.
| In the JSON | In the spreadsheet |
|---|---|
["new", "priority"] | new; priority |
["a", null, "b"] | a; null; b |
[] | a blank cell |
[1, {"b": 2}], objects mixed with plain values | the list as JSON text |
[[1, 2], [3]], lists inside a list | the list as JSON text |
The last two have no fair grid layout, so they go in as JSON text and the page counts them. A key whose list or object is empty in every record still gets its column, so you can see the field exists.
Lists of objects become linked sheets
Items are different: each has fields of its own, and an order can have any number. Every item from every order goes on one sheet named after its path, and a number column ties each item to its order. The main sheet gains a # column numbering its rows, and the items sheet a data # column holding the parent's number:
| data # | sku | qty |
|---|---|---|
| 1 | TSHIRT-M | 2 |
| 1 | MUG-01 | 1 |
| 2 | SHOES-9 | 1 |
The link column takes the parent's name followed by #. A file shaped {"customers": [{"name": …, "orders": [{"no": …, "items": [...]}]}]} gives three sheets, customers, customers.orders and customers.orders.items, linked by customers # and customers.orders #. The middle sheet has both a # column of its own and a link column. If the same customers are a plain list instead, the sheets are data, orders and orders.items. To see one order's items, filter the child sheet on its link column; the .xlsx has a filter on each header row.
Copying parent columns down
With only a link column, answering "who bought this?" needs a lookup. The option to copy the parent row's columns repeats every ancestor field on each child row, named <parent>.<column>. For a child of a top-level sheet that has no lists of its own:
child columns = 1 link column + the parent's own columns + the child's own columns
The items sheet in the orders example then has 1 + 5 + 2 = 8 columns: data #, data.id, data.customer.name, data.customer.city, data.customer.pin, data.tags, sku and qty. The parent's # is not copied. Further down the copies accumulate, so in the customers file each item row carries customers.name and customers.orders.no as well as its own fields.
Which list becomes the main sheet
When the JSON is a list, each item is a row on a sheet called data. An object may hold several lists, so it needs a rule. Every list of objects reached through objects alone becomes a sheet, and the one with the most rows comes first as the main sheet. A tie goes to the list found first. A list inside another list's rows is a child sheet, and never the main one. The object's loose fields go on a one-row sheet called details, placed last.
{
"shop": "Asha Stores",
"city": "Pune",
"orders": [{ "no": 1 }, { "no": 2 }, { "no": 3 }],
"returns": [{ "no": 2 }]
}
| Sheet | Rows | Why |
|---|---|---|
| orders | 3 | the longest list, so first |
| returns | 1 | another list of objects |
| details | 1 | shop and city, outside any list |
The main sheet is the one the preview opens on and the CSV holds unless you switch. Shared wrappers are trimmed from names: in {"response": {"data": {"orders": [...], "returns": [...]}}} the common response.data is dropped, leaving orders and returns. An object whose values are all records with largely the same fields, such as {"u1": {...}, "u2": {...}}, is read as rows, with the names in a first column called key, or _key if a record already has a field called key.
Leading zeros and numbers past 15 digits
Leading zeros survive only as text. JSON does not allow a number to be written with leading zeros, and the converter reports 007 as a mistake, so a code such as 007 has to arrive quoted, as "007". The .xlsx writes quoted values as text cells, so "411001" and "07" stay as written, while real numbers become number cells you can add up. JSON's true and false become boolean cells, and null an empty cell.
Excel stores only 15 significant digits of a number, so a longer one such as 12345678901234567890 cannot be held exactly: the digits after the fifteenth are lost.
There is an earlier limit. JavaScript, which reads the JSON in your browser, guarantees to hold whole numbers exactly only up to 2^53 − 1 = 9007199254740991, its Number.MAX_SAFE_INTEGER. Past that, a plain parse turns 9007199254740993 into 9007199254740992 before Excel ever sees it.
The converter reads each number's original digits and writes any whole number longer than 15 digits as text. That needs a browser whose JSON.parse passes each number's source text to a reviver: Chrome and Edge from version 114, and recent Firefox and Safari. Elsewhere a warning says such numbers may be rounded. Decimals are not covered.
CSV or XLSX
A CSV has no way to mark a cell as text or as a number, so a spreadsheet opening one has to guess. The CSV of the orders begins:
#,id,customer.name,customer.city,customer.pin,tags
1,101,Asha Rao,Pune,411001,new; priority
The file is exact. What changes the data is Excel's guess on opening: 07 can come back as 7, and a 20-digit number loses every digit after its fifteenth.
| .xlsx | .csv | |
|---|---|---|
| Sheets | all, in one file | the sheet being previewed |
| Codes such as 007 | text, zeros kept | Excel may drop the zeros |
| Whole numbers over 15 digits | text, every digit kept in a supporting browser | Excel may round them |
| true and false | boolean cells | the words |
| Header filter | yes | no |
| Columns past 16,384 | cut | kept |
| Text past 32,767 characters | cut | kept |
The CSV is UTF-8 with a byte-order mark, which lets Excel show ₹ and accented letters, and has CRLF line ends. Use the .xlsx when a person opens the file in Excel, and the CSV when a program reads one flat table. For scale, 14.3 MB of JSON for 30,000 customers gave a 9.07 MB .xlsx holding all three sheets, of 30,000, 60,000 and 60,000 rows, and a 3.29 MB CSV of the main sheet alone.
Excel size limits and sheet names
An Excel worksheet holds at most 1,048,576 rows by 16,384 columns, and 32,767 characters in a cell. The header takes a row, so:
data rows per sheet = 1,048,576 − 1 = 1,048,575
Rows past that are left out of both downloads, and the page counts them. A child sheet collects the rows of every parent, so it can reach the limit while its parent is far short.
Sheet names allow at most 31 characters and none of \ / ? * [ ] :. Long paths are shortened from the left, so the part that says what the sheet holds survives:
| Path | Sheet name |
|---|---|
| customers.orders.items.variants.prices.history (46 characters) | items.variants.prices.history |
| a/b | a_b |
| History | History_ |
| items, then Items | items, Items (2) |
A sheet named like 2024 or A1 gets no header filter, which would otherwise make Excel offer to repair the file.
JSON Lines files
JSON Lines, saved as .jsonl or .ndjson, has one complete JSON value per line, no brackets round the file and no commas between lines. The converter splits the text at each line break and trims every line, so Windows line ends work too and blank lines are skipped.
Paste or open such a file as it is: each line becomes a row, and a note says it was read as JSON Lines. There must be at least two lines, each valid on its own. If one is broken, the text is treated as a single document, so the error position may not be the broken line; paste suspect lines alone to find it. Two objects on one line are not JSON Lines: wrap them in [ ] with a comma between, or give each its own line.
Finding the mistake in broken JSON
The converter reports the first mistake by line and column, shows that line with a caret under the fault, and names the likely cause in plain English. Its own scanner writes the message, so it reads the same in every browser.
{
"name": "Asha",
"tags": ["new", "priority",]
}
This reports line 3, column 29: a comma before the ]. Column 29 is the comma, not the bracket, so the character to delete sits above the caret.
| What was written | What JSON wants |
|---|---|
'Asha' | "Asha" |
{name: "Asha"} | {"name": "Asha"} |
// note or /* note */ | nothing: JSON has no comments |
True, False, None | true, false, null |
undefined | null |
NaN, Infinity | null, or the word in quotes as text |
007 | "007" |
"C:\Users" | "C:\\Users" |
| “curly quotes” | straight " |
Invisible no-break and zero-width spaces, often pasted from a chat or web page, are named too. To check or tidy JSON without a spreadsheet, use the JSON Formatter & Validator.
What this tool assumes
- It reads structure, not meaning: names come from the JSON's own keys. GST portal return files have their own GSTR JSON to Excel Converter.
- Up to 20 MB (20 × 1,024 × 1,024 bytes) per conversion. A file over 1 MB is converted straight from the file instead of opening in the box.
- Everything runs in your browser tab. At desktop speed a 14 MB file took about 0.5 s to read and flatten and about 2.2 s to write the .xlsx; slower phones take longer.
- The .xlsx is compressed less tightly than Excel's own saves, so it is roughly three times the size Excel would make.
- The preview shows the first 50 rows and 60 columns of each sheet. The downloads have every row and column, within the Excel limits above.
- Nesting deeper than 50 levels is written into the cell as JSON text.
- Nothing is uploaded. The spreadsheet library is loaded from calci.in the first time you download an .xlsx, without your data.