Data

JSON to CSV without breaking Excel

Turning an object into rows takes four lines of code. Keeping the customer number 007 and the accented surname intact is the part that decides whether the file is usable.

The conversion is the easy half. An array of objects becomes a header row and a bunch of data rows, the keys become the columns, and any competent script does it before lunch. What actually decides whether the file is any use is what happens on the other side, when someone double-clicks it in Excel and starts reading. That is where the damage gets done, and the damage is almost never caused by the converter being wrong. It is caused by the format being asked to carry information it has no way to record.

I want to go through the failure modes in the order I meet them, which is roughly the order of how often they bite.

Leading zeros and the number-shaped string

This is the one that starts arguments, because the data is arguably at fault. Consider a customer table where the account code for one branch is 007. In JSON that value might be the string "007", in which case the converter writes 007 into the file, and then Excel reads the file, decides the cell looks like a number, and displays 7. The leading zeros are gone. Nobody edited anything; the parse did it.

The same thing happens more dramatically with long digit strings. A phone number like 0412345678 is still a plain number to Excel and survives, but a sixteen digit identifier does not. Excel's numeric type carries fifteen significant decimal digits. Past that, the value is stored as a double and the last digits are lost or rounded, and long enough strings get displayed in scientific notation instead. Paste 1234567890123456 into a general cell and you get 1.23457E+15, with the trailing 6 unrecoverable because it was never stored in the first place. This is not a display setting. The data underneath the display is a float.

The fix people reach for is a leading apostrophe, and it works, but it works by lying. Typing '007 tells Excel "the rest of this is text" and the apostrophe is not part of the value. There is no equivalent in a CSV file. A CSV field is just characters between separators, and the file format has no column types at all: there is no way for the file to say that column three is an identifier that happens to be made of digits. Some converters will emit ="007" in a cell, which is a formula that evaluates to the text 007 and therefore displays correctly. It also means the column now contains formulas, which is fine right up until someone opens it in anything that is not Excel, and it is a well-known route for spreadsheet formula injection if the source data is untrusted.

So the honest framing is a choice, not a fix. You are picking between a file that behaves well when Excel has a go at it and a file that is faithful to what the data actually said. Quoting every field is not enough on its own — Excel will still convert a quoted numeric-looking field to a number on import. The only dependable option for a code like 007 is to not let Excel guess: import through Data, From Text, and set the column type to Text on the preview screen. If the file has to be double-clickable, then the column has to be shaped so it cannot be read as a number.

Separators, quotes and the newline problem

The second family of failures is pure syntax, and it is the one where the converter itself is genuinely responsible. A CSV field containing a comma has to be wrapped in double quotes, or the parser sees two fields where there was one. A field containing a double quote has to have that quote doubled — He said "hi" is written as "He said ""hi""" — and a parser that does not implement that rule will produce garbage on the very first record that contains a quotation mark. An address like 12 Rue de l'Église, Apt 3 hits the first rule; a product description with an inch mark in it hits the second.

The one that catches people out is the newline. RFC 4180, the document that describes what CSV actually is, permits a line break inside a quoted field. A field spanning two lines is legal. A parser written by hand — split on commas, split on newlines — will treat that one record as two, and every row after it shifts by one column for the rest of the file. I have spent more time than I would like debugging exactly this, and the symptom is always a column of names that starts halfway down the sheet. If your JSON has a notes or description field, assume it contains a newline and assume your parser does not handle it.

The byte order mark, and why an accent goes wrong

Encoding does not usually hurt anyone until a name with a diacritic shows up. Excel's older CSV import path assumes the system codepage when it cannot tell what the file is, and on a Windows machine that is often Windows-1252. A UTF-8 encoded José is the bytes 4A 6F 73 C3 A9. Read those five bytes as Windows-1252 and the last two become é, so the cell says José. That string is called mojibake and there is no way to un-see it once it is in a report.

The fix is the UTF-8 byte order mark: three bytes, EF BB BF, written before the first character of the file. Excel sees them, concludes the file is UTF-8, and decodes accordingly. It is a hack with real costs. Command-line tools that read the file will hand the three bytes to your application as part of the first field, so a header that should read id arrives as id — the same word with an invisible character in front of it, which breaks every string comparison against it. JSON does not permit a leading BOM in most parsers, and CSV is the only format where adding one is safe. That is why every converter has a BOM toggle and why the correct answer depends entirely on who is going to open the file.

What the damage looks like together

Here is the reference I keep. Left to right: what goes wrong, what causes it, and what actually stops it.

What you see What caused it The fix
007 becomes 7 Excel infers a numeric type from the digits; the field is written unquoted or quoted, and either reads the same Import via Data, From Text, and set the column type to Text; no CSV-level type marker exists
1234567890123456 becomes 1.23457E+15 Excel holds fifteen significant digits in a double; the rest is not stored Same as above, or emit the value as text through ="..." and accept formulas in the column
One row becomes two A line break inside a quoted field; a naive parser splits on newlines before it honours quotes Doubled quotes plus real RFC 4180 parsing, or strip line breaks before export
Everything shifts one column left An unquoted comma inside a value Quote any field containing a comma, quote or newline
He said "hi" splits apart A double quote in the value was not doubled to "" Double every interior quote and wrap the field
José instead of José UTF-8 bytes decoded as the system codepage on import Write a UTF-8 BOM, or import with an explicit UTF-8 source encoding
id as the first header The BOM landed inside the first field for a tool that does not strip it Toggle the BOM off for machine-read files, on for hand-opened ones
A date that arrives as 3 April or 4 March Date inference, with day and month swapping depending on locale Emit ISO 8601 strings, or let the column be text

Every row above shares one theme: Excel or a parser is making a reasonable guess about a type that CSV never recorded. The format has no schema, so the guessing is not a bug in the importer. It is the format working as designed.

Flat is a lie: JSON is a tree, CSV is a table

Everything above is recoverable. This is the part that is not, because it is not damage at all — it is a mismatch of shape, and one of the two sides has to give. CSV has two dimensions: rows and columns. JSON has as many as you want. Nested objects, arrays of objects, arrays of scalars, keys that only some records have. The moment a record contains anything below the first level, the table cannot hold it without a decision being made, and that decision is the whole job.

Take a real order feed. This is the shape I get on about half the projects I touch:

[
  {
    "orderId": "A-1043",
    "customer": {
      "name": "José Ramírez",
      "account": "007"
    },
    "total": 149.5,
    "items": [
      { "sku": "BOLT-M8", "qty": 100 },
      { "sku": "NUT-M8",  "qty": 100 }
    ]
  }
]

The traps are already waiting inside that object. customer is an object with two keys. items is an array whose length varies per order. And account is the leading-zero problem from earlier, sitting inside the nesting.

Flatten it with dotted paths and the columns name themselves by where the value came from:

orderId,customer.name,customer.account,total,items.0.sku,items.0.qty,items.1.sku,items.1.qty
A-1043,José Ramírez,007,149.5,BOLT-M8,100,NUT-M8,100

That file is faithful, parseable and completely hostile to read. The number of columns depends on the longest array in the whole dataset, so one order with forty items gives you eighty item columns and eighty empty cells on every other row. It is also fragile: add a third item and the header changes. But if the destination is a script rather than a person, this is often exactly the right output, because the shape is predictable in code even when it looks awful in a spreadsheet.

The alternative for arrays is to serialise them: keep one row per order, and put the array in a single cell as a JSON string. You get a stable column count and a file a human can scan, at the cost of a cell containing [{"sku":"BOLT-M8","qty":100},...] that has to be double-quoted and comma-stuffed to survive the trip. It is a perfectly good answer when the array is a detail nobody queries, and a bad one when it is the point of the dataset.

The third option is to stop pretending one file is one table. Two CSVs — an orders file and an items file joined on orderId — is the normal relational answer, and it is what I recommend when both halves get analysed. It costs the reader a join, which is cheap in a database and mildly annoying in a spreadsheet, but it keeps every value in a real column with a real type and it does not fall apart when an order has nineteen items.

Strategy Shape of the output Right when
Dotted paths customer.name, items.0.sku, one row per record The consumer is code, the nesting is shallow and the arrays are fixed-length
JSON string in the cell One row per record, the nested part kept intact as text in a column The nested part is reference detail nobody filters on, and the sheet has to stay human-readable
Two files, joined on a key An orders table and an items table sharing orderId Both levels get analysed, the array length is unbounded, or the data is headed for a database
Long format One row per leaf value: record, field, value The schema is unknown or drifting, and you need every value in one typed column
Leave it as JSON No conversion at all The receiver can read JSON, which is most software written this decade

The last row is not a joke. The single most common mistake with JSON to CSV is doing it at all: flattening a structure that the receiving tool would have read directly, and losing the nesting in exchange for nothing.

When the array is not an array of objects

Every rule above assumed a list of objects. Large amounts of real JSON is not that. A geolocation endpoint hands you [[51.5074, -0.1278], [40.7128, -74.0060]]. A tag list is ["red", "green", "blue"]. A key-value export is a single object, not an array at all. Each of those needs a different decision, and a converter that assumes objects will produce one column called 0 and a lot of empty cells.

Arrays of arrays are positional by definition, so the header has to be positional too. If you cannot name the columns from the data, name them from what you know: the first field is a latitude and the second is a longitude, so write lat,lon as the header rather than 0,1. An array of scalars is one column, and if it is a list of tags you have a choice between joining them into one cell with a delimiter or exploding them into one row per tag — the second is almost always better if anyone will ever count them. A bare object is a one-row table, which is legal but rarely what was wanted; if you were expecting an array, the bug is upstream and you should fix it there rather than converting the wrong thing carefully.

There is a diagnostic step worth doing before any conversion: look at the actual structure, not the structure you remember. Whether the top level is an array or an object, whether the keys are consistent across records, and where the nesting really is — all of that changes which of these cases you are in, and guessing at it and then debugging the CSV is the slow way round.

Where the tools fit. The JSON to CSV converter is the one that does the work described here: it takes an array of objects, flattens the nesting, and gives you the options this article is about — dotted paths or JSON strings for nested values, and a UTF-8 BOM toggle for the Excel encoding problem. Turning that toggle on is the difference between José and José when the file is opened by double-click, and turning it off is the difference between a usable header and one with an invisible character in front of the first key.

Before any of that, the JSON Formatter is where I check what I am actually converting. An array of objects flattens into a clean table. An object wrapping an array under a key like data needs one more step of drilling down first, and an array of arrays needs a header invented for it.

The rule that survives all of this

Before you convert, classify every column of the source as either a measurement or a name. Numbers that get added, averaged or plotted are measurements. Everything else is a name, including things that look like numbers: account codes, phone numbers, postcodes, card identifiers, version strings, and anything with a leading zero. Measurements want to arrive in Excel as numbers. Names want to arrive as text, and the only reliable way to make that happen is to set the type on import, because the file cannot carry the instruction.

The check to run on any CSV you are about to send someone is short. Open it in a text editor, not a spreadsheet, and read the raw text rather than the rendered table. Does the header have a strange character before the first name, which means the BOM will leak into somebody's code. Are there fields with commas or quotes in them that are not quoted. Does any row have a different number of separators from the row above it, which means a newline is loose inside a field. Those checks take about a minute by eye and catch nearly everything on the list above.

The deeper lesson is that CSV is a transport format with no memory. It can carry values and it cannot carry meaning, so every type in your data is re-derived by whoever opens the file, using whatever rules their tool prefers. JSON keeps the distinction between "007" and 7; CSV cannot, because the quotes around a CSV field are syntax, not a type declaration. If you need the distinction to survive the trip and a human has to read the result, name the file so the recipient knows to import it rather than open it, and say which columns are text. That sentence, written in an email, is worth more than any converter setting.