CSV is a flat table and JSON is not, so the work is flattening. Each nested object becomes columns named by path — address.city, address.geo.lat — and the header is the union of every path in every row, not just the first, so a field only some records carry still gets a column, next to its siblings. Arrays are the real decision. Kept as JSON in one cell, the default, a list of tags stays one value you can parse back; expanded by index, tags.0 and tags.1 become columns, which suits a short fixed list and not a long ragged one. An empty object or array is written as {} or [] so it is not mistaken for a missing field, while null and a missing field both become an empty cell, because CSV cannot tell them apart. A key that already contains a dot reads like a nested path, and if the two collide the later column gets a suffix and the result says so. JSON Lines, one object per line, works too.
A correct CSV can still be dangerous to open. Excel, Google Sheets and LibreOffice treat a cell beginning with =, +, -, @, a tab or a line break as a formula, so a customer name of =HYPERLINK(…) in an exported table becomes a live link, and OWASP describes payloads that exploit the spreadsheet or leak its contents. Every such cell is counted and reported whether or not you escape it. Escape formulas applies one of the two prefixes OWASP gives — an apostrophe, which spreadsheets display as text, or a tab inside quotes, which OWASP finds more reliable in Excel — and both change what a program reading the file will see, which is why it is off by default. Numbers are not formulas: a JSON -5, or the text "-5", is left alone, while "-5+3" and "+44 20 7946 0000" are flagged.
Two further problems sit either side of the conversion. After it, Excel opens a CSV that has no byte order mark in a legacy encoding, so café arrives as café; Microsoft’s guidance is that a UTF-8 CSV opens correctly when saved with a BOM, which Add BOM for Excel adds to the download and never to the text you copy. Before it, JSON.parse has already rounded any integer past 2^53: 12345678901234567890 becomes 12345678901234567000 before there is anything to convert. That cannot be repaired afterwards, so the source text is scanned for long integers, and any that were rounded are counted, with the first one shown. The fix is upstream — send ids as strings.