What gets lost converting between JSON, YAML, CSV and XML

How JSON, YAML, CSV, XML and TOML differ on comments, types, nesting, dates and key order, and which of those details each conversion between them quietly throws away.

Every conversion between data formats drops what the source could express and the destination cannot. Some losses are obvious: comments vanish the moment YAML becomes JSON. Most are quiet. A date arrives as a string, an array becomes five columns, an ID ending in 3 now ends in 2. The useful habit is knowing which detail will go before it goes.

What each format can carry

CommentsTypesNestingDatesKey orderDuplicate keys
JSONNostring, number, boolean, null, object, arrayYesNo native typeUnordered by spec, kept by most parsersUndefined, last usually wins
YAMLYes, #JSON's, plus timestamps and more in 1.1YesYes in 1.1, tag dependent in 1.2Kept in the file, not after parsingInvalid, but often accepted
CSVNoNone, every cell is textNo, flat rows onlyNoFixed by the header rowLegal and ambiguous
XMLYes, <!-- -->None without a schema, typed with XSDYesOnly through a schemaElements ordered, attributes notRepeated elements normal, duplicate attributes an error
TOMLYes, #string, integer, float, boolean, date, time, date-time, array, tableYes, via tablesYes, first classNot significantAn error, always

Two columns cause most of the surprises. Types decides whether 007 survives or arrives as the number 7, and nesting decides whether a conversion to CSV is possible without inventing a convention.

CSV is flat, and every cell is text

CSV has one shape: rows of fields. There is no way to express an object in a cell, so a converter has to invent something. Usually it flattens with dotted paths, so this:

[{ "id": 1, "name": { "first": "Ada" }, "tags": ["admin", "ops"] }]

becomes:

id,name.first,tags.0,tags.1
1,Ada,admin,ops

That unflattens cleanly until two things happen. Arrays of different lengths widen the header to the longest record, so one row with three tags adds a tags.2 column and leaves every other row an empty cell. And a key already containing a dot is indistinguishable from a nested path afterwards, so the trip back rebuilds the wrong structure. The alternative is a JSON string in the cell, "[""admin"",""ops""]" with the quotes doubled as CSV requires, which loses nothing and which nothing downstream understands.

Types are the second half. A cell holds characters, so a reader has to guess. Guessing gives 007 as 7, 1.0 and 1 as one value, a postcode without its leading zero, a company code of TRUE as a boolean. Not guessing gives every number as a string. Type inference in CSV to JSON, or in TSV to JSON, is a guess with sensible defaults rather than a recovery, because the original types were never written down.

A blank cell may mean empty string, null, zero or not applicable; decide what it means and say so, because the file cannot. Swapping separators, as CSV to TSV does, is the one safe conversion here: there are no types to damage.

YAML guesses the type for you

YAML infers types from unquoted text, which is what makes it pleasant to write and risky to convert.

country: no
version: 1.20
build: 010
start: 12:30

Under a YAML 1.1 parser, which is what PyYAML and many older libraries still implement, that is:

{ "country": false, "version": 1.2, "build": 8, "start": 750 }

All four changed meaning. no is the Norway problem: YAML 1.1 treats y, yes, no, on and off as booleans, so the country code becomes false. 1.20 is a float, so the trailing zero is gone. 010 matches the octal pattern and becomes 8. 12:30 matches the sexagesimal pattern and becomes 750, a count of minutes.

A YAML 1.2 core schema parser such as current js-yaml reads the same file as "no" the string, 010 as 10, and "12:30" as a string. One file, two parsers, different data. The specifications genuinely disagree; 1.2 dropped the sexagesimal and boolean-word rules deliberately.

The defence is one character. Quote anything meant to be text: country: "no", version: "1.20". The YAML and JSON converter shows what a parser made of a file, because JSON has nowhere to hide the ambiguity.

JSON has no dates, no comments and a 53 bit ceiling

JSON numbers are IEEE 754 doubles, so integers stay exact only to 2^53, which is 9007199254740992. Past that:

{ "id": 9007199254740993 }

Parse and re-serialise that in most languages and it returns as 9007199254740992. The value cannot be represented at all, so it is less rounded than unavailable. This is why Discord, Twitter and other snowflake APIs send IDs as strings: a 64 bit integer does not fit in a JSON number.

There is no date type either, so dates travel as strings, conventionally ISO 8601, and nothing marks "2026-08-25" as a date rather than text. Through TOML or a YAML 1.1 parser it may become a real date, then return formatted differently. JSON also has no comments and no trailing commas, so annotations in a YAML or TOML source are gone rather than moved. Generating TypeScript interfaces from a sample inherits both blind spots: a string is not a date, and an absent field is not optional.

XML does not fit inside JSON, in either direction

XML distinguishes attributes from child elements, and JSON has only keys.

<user id="7" active="true">
  <name>Ada</name>
  <tag>admin</tag>
  <tag>ops</tag>
</user>

A converter picks a convention, usually prefixing attributes with @ and collapsing repeated elements into an array. That is lossy in a specific way: with a single <tag> there is no array, so the output shape depends on the data rather than the schema, and code expecting a list breaks on the record that has one tag. Mixed content such as <p>Hello <b>there</b> friend</p> has no JSON equivalent, and namespaces, CDATA and comments disappear.

The reverse loses different things. Element names cannot start with a digit or contain spaces, so arbitrary JSON keys must be mangled. null becomes an empty element or xsi:nil by convention. A top level array needs a wrapper element that was never in the source. And nothing in JSON records whether a value was once an attribute, so JSON to XML emits elements only. CSV to XML makes that choice openly, asking for elements or attributes.

Key order and duplicate keys

Key order is guaranteed nowhere and preserved almost everywhere. The JSON specification calls objects unordered, yet every mainstream parser keeps insertion order. Sorting keys is still safer for anything compared or checksummed, since a diff of two documents whose keys merely moved is unreadable. Comparing structurally, as the JSON comparison tool does, matches on key rather than position and avoids the question.

Duplicate keys are the sharper edge. JSON says nothing, so parsers differ and most silently keep the last value. YAML forbids them and many parsers accept them anyway. TOML rejects them outright. CSV allows two columns with the same header and leaves the reader to decide, which is reason enough to run a CSV validator over an unfamiliar file first. XML is the only one where repetition is meaningful rather than accidental.

Converting on purpose

Every pipeline has a lossy step. Choose which one it is instead of discovering it later.

  • Reach the narrowest format last. XML to JSON to CSV loses attributes at the first step and nesting at the second. If CSV is the destination, decide which fields matter and flatten deliberately.
  • Quote before converting, not after. Version numbers, country codes, postcodes and anything with a leading zero belong in quotes in the source. Once no is false, the original text is unrecoverable.
  • Check the output as data. Format it, confirm the record count, and inspect a known awkward row: the comma in a name, the longest array, the largest ID.
  • Diff a round trip. Convert out, convert back, compare. What the comparison reports is what the conversion cannot carry.
  • Keep the original. The converted file is a derivative, and when someone asks in three months whether a field was empty or absent, only the source can answer.