JSON and CSV aren't two encodings of the same shape — JSON nests, CSV is flat. Every JSON-to-CSV converter has to make a judgment call about what to do with that mismatch, and every CSV-to-JSON converter has to guess at types a CSV cell was never able to express in the first place. Neither direction is lossless, and knowing exactly where the loss happens is the difference between a conversion you can trust and one that quietly corrupts a column you weren't watching.
Nested objects flatten. Nested arrays don't.
A JSON-to-CSV converter has to turn a tree into rows and columns, and the standard move for a nested object is to flatten it: walk every key path and join it with dots, so {"user":{"name":"Ana"}} becomes a column literally named user.name. An array gets no such treatment — there's no obvious column name for "the third tag," so it's serialized back into a JSON string and dropped into a single cell, quotes and all:
{ "user": { "name": "Ana" } }
column: user.name
cell: Ana
a nested object becomes a dotted column name
{ "tags": ["vip", "beta"] }
column: tags
cell: "[""vip"",""beta""]"
a nested array is not flattened — it's stringified into one cell
same flattening pass, two different outcomes depending on whether the nested value is an object or an array
That asymmetry is invisible until you open the result in a spreadsheet and find a literal ["vip","beta"] sitting in one cell instead of two separate columns or rows. It's not a bug — there genuinely isn't a universal right answer for flattening an array into CSV's grid — but it means "convert to CSV" quietly becomes "convert to CSV, except arrays, which become JSON text" the moment your data has any.
Columns are a union, not an intersection
Real JSON arrays are rarely uniform — one record has an optional field, another doesn't. A CSV has to have one fixed set of columns for every row, so the converter collects every key it sees across every object, in the order it first saw it, and uses that as the full column list:
[{ "a": 1, "b": 2 }, { "a": 3, "c": 4 }]
→ a,b,c
1,2,
3,,4Row two never had a b, and row one never had a c — both get a blank cell instead of the column disappearing or the row being rejected. On a real dataset with a rare optional field buried in record 4,000 of 5,000, this is exactly how a CSV ends up with a column almost nobody asked for, and most of a spreadsheet full of blanks under it.
Going back: a CSV cell is always a string, until something decides otherwise
CSV has no type system — every cell is text. Converting back to JSON means guessing at the original type from the text alone, and that guess has to be conservative about exactly the kind of string that looks numeric but isn't meant to be treated as a number:
zip,qty,note
00451,7,
90210,10,ok
→ [
{ "zip": "00451", "qty": 7, "note": null },
{ "zip": 90210, "qty": 10, "note": "ok" }
]00451 stays a string — the type-inference rule specifically rejects leading zeros, because a real postal code, account number, or ID that happens to look numeric should never silently become 451. 90210, with no leading zero, infers cleanly to a number. And the blank note cell becomes null — which means a cell that was truly empty and a cell that explicitly contained the text "null" would land in different places, but a blank cell and a deliberately-empty string cell become indistinguishable. That distinction existed in whatever produced the CSV and doesn't survive the trip back.
The two directions are near-inverses — until an array breaks the symmetry
Dot-notation flattening has a matching unflatten step: a column named user.name rebuilds a nested user object on the way back to JSON, exactly undoing the flatten. But the earlier asymmetry means this symmetry only holds for objects. Round-trip an object with a nested array through both conversions, and the array comes back as the literal string it was serialized to — not reparsed, not restored:
{ "user": { "name": "Ana" }, "tags": ["vip", "beta"] }
→ CSV → user.name,tags
Ana,"[""vip"",""beta""]"
→ JSON → { "user": { "name": "Ana" }, "tags": "[\"vip\",\"beta\"]" }user comes back as a real nested object, correctly rebuilt from the dotted column. tags comes back as a string that merely looks like JSON — it takes an explicit extra parse to turn it back into an actual array. A round trip through CSV is safe for anything made of objects and primitives; the moment an array is involved, it isn't a round trip anymore.
Try it yourself
JSON to CSV runs the flatten/union-of-columns logic above on real JSON you paste in, and CSV to JSON runs the reverse — including the type-inference rule and an optional unflatten step that rebuilds nested objects from dotted column names. Both run entirely in your browser.